@pubuduth-aplicy/chat-ui 2.1.49 → 2.1.51

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pubuduth-aplicy/chat-ui",
3
- "version": "2.1.49",
3
+ "version": "2.1.51",
4
4
  "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.",
5
5
  "license": "ISC",
6
6
  "author": "",
@@ -0,0 +1,91 @@
1
+ import React from 'react';
2
+
3
+ export type FileType = 'image' | 'video' | 'document' | null;
4
+
5
+ interface FilePreviewProps {
6
+ file: File;
7
+ type: FileType;
8
+ previewUrl: string;
9
+ onRemove: () => void;
10
+ uploadProgress?: number;
11
+ uploadError?: string;
12
+ }
13
+
14
+ export const FilePreview: React.FC<FilePreviewProps> = ({
15
+ file,
16
+ type,
17
+ previewUrl,
18
+ onRemove,
19
+ uploadProgress,
20
+ uploadError
21
+ }) => {
22
+ const getFileIcon = () => {
23
+ switch (type) {
24
+ case 'document':
25
+ return (
26
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
27
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
28
+ <polyline points="14 2 14 8 20 8"></polyline>
29
+ </svg>
30
+ );
31
+ default:
32
+ return null;
33
+ }
34
+ };
35
+
36
+ const formatFileSize = (bytes: number) => {
37
+ if (bytes === 0) return '0 Bytes';
38
+ const k = 1024;
39
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
40
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
41
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
42
+ };
43
+
44
+ return (
45
+ <div className="file-preview">
46
+ <div className="file-preview-content">
47
+ {type === 'image' && (
48
+ <img src={previewUrl} alt={file.name} className="file-preview-image" />
49
+ )}
50
+ {type === 'video' && (
51
+ <video controls className="file-preview-video">
52
+ <source src={previewUrl} type={file.type} />
53
+ </video>
54
+ )}
55
+ {type === 'document' && (
56
+ <div className="file-preview-document">
57
+ {getFileIcon()}
58
+ <span>{file.name}</span>
59
+ </div>
60
+ )}
61
+
62
+ <div className="file-info">
63
+ <span className="file-name">{file.name}</span>
64
+ <span className="file-size">{formatFileSize(file.size)}</span>
65
+ </div>
66
+
67
+ {uploadProgress !== undefined && uploadProgress < 100 && (
68
+ <div className="upload-progress">
69
+ <div
70
+ className="progress-bar"
71
+ style={{ width: `${uploadProgress}%` }}
72
+ ></div>
73
+ </div>
74
+ )}
75
+
76
+ {uploadError && (
77
+ <div className="upload-error">{uploadError}</div>
78
+ )}
79
+ </div>
80
+
81
+ <button
82
+ type="button"
83
+ className="remove-file"
84
+ onClick={onRemove}
85
+ aria-label="Remove file"
86
+ >
87
+ &times;
88
+ </button>
89
+ </div>
90
+ );
91
+ };
@@ -21,8 +21,8 @@ const Message = ({ message }: MessageProps) => {
21
21
 
22
22
  const { userId } = useChatContext();
23
23
  const fromMe = message.senderId === userId;
24
- const bubbleBgColor = fromMe ? "chatMessagesBubble_me" : "chatMessagesBubble_Other";
25
- const alignItems = fromMe ? "chatMessagesBubble_me" : "chatMessagesBubble_Other";
24
+ const timestamp = fromMe ? "timestamp_outgoing" : "timestamp_incomeing";
25
+ const alignItems = fromMe ? "outgoing" : "incoming";
26
26
 
27
27
  const date = new Date(message.createdAt);
28
28
  const hours = date.getUTCHours();
@@ -58,19 +58,17 @@ const getStatusIcon = () => {
58
58
  </div>
59
59
  </div> */}
60
60
 
61
- <div className='chatMessage'>
62
- <div className="chatMessagesContainer">
63
- <div style={{color:"#374151"}}>
64
- <div className={`chatMessagesBubble_inner ${alignItems} ${bubbleBgColor} `}>
65
- <p style={{fontSize:"14px"}}>{message.message}</p>
66
- <div className='chatMessagesBubble_Time'> {new Date(message.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
67
- {getStatusIcon()}
68
- </div>
61
+ <div className="chat-container">
62
+ <div className={`message-row ${alignItems}`}>
63
+ <div className="bubble-container">
64
+ <div className="chat-bubble">{message.message}</div>
65
+ <div className={`${timestamp}`}>{new Date(message.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
66
+ <span className="status-icon">{getStatusIcon()}</span>
69
67
  </div>
70
-
71
- {/* <div className="clear-both flex text-gray-700" /> */}
72
- </div>
73
- </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
74
72
 
75
73
  </>
76
74
  )
@@ -1,13 +1,38 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import React, { useCallback, useEffect, useState } from "react";
2
+ import React, { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { useMessageMutation } from "../../hooks/mutations/useSendMessage";
4
4
  import { useChatContext } from "../../providers/ChatProvider";
5
5
  import useChatUIStore from "../../stores/Zustant";
6
6
  import paperplane from "../../assets/icons8-send-50.png";
7
7
  // import { PaperPlaneRight } from '@phosphor-icons/react'; // Assuming you're using icons from Phosphor Icons library
8
8
  // import useSendMessage from '../../hooks/useSendMessage'; // Importing the useSendMessage hook
9
+ import { FilePreview, FileType } from "../common/FilePreview";
10
+ import { getApiClient } from "../../lib/api/apiClient";
11
+ const MAX_FILE_SIZE_MB = 10; // 10MB max file size
12
+ const ACCEPTED_IMAGE_TYPES = [
13
+ "image/jpeg",
14
+ "image/png",
15
+ "image/gif",
16
+ "image/webp",
17
+ ];
18
+ const ACCEPTED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/ogg"];
19
+ const ACCEPTED_DOCUMENT_TYPES = [
20
+ "application/pdf",
21
+ "application/msword",
22
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
23
+ "text/plain",
24
+ ];
25
+
26
+ interface Attachment {
27
+ file: File;
28
+ type: FileType;
29
+ previewUrl: string;
30
+ uploadProgress?: number;
31
+ uploadError?: string;
32
+ }
9
33
 
10
34
  const MessageInput = () => {
35
+ const apiClient = getApiClient();
11
36
  const { socket } = useChatContext();
12
37
  const { userId } = useChatContext();
13
38
  const { selectedConversation } = useChatUIStore();
@@ -18,6 +43,7 @@ const MessageInput = () => {
18
43
  const [isSending, setIsSending] = useState(false);
19
44
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
20
45
  const [isTyping, setIsTyping] = useState(false); // Track if the user is typing
46
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
21
47
 
22
48
  useEffect(() => {
23
49
  if (selectedConversation?._id) {
@@ -46,17 +72,28 @@ const MessageInput = () => {
46
72
  return () => clearTimeout(typingTimeout);
47
73
  }, [message, socket, selectedConversation?._id, userId]);
48
74
 
49
-
50
75
  useEffect(() => {
51
76
  if (!socket || !selectedConversation?._id) return;
52
77
 
53
- const handleTyping = ({ userId, chatId }: { userId: string; chatId: string }) => {
78
+ const handleTyping = ({
79
+ userId,
80
+ chatId,
81
+ }: {
82
+ userId: string;
83
+ chatId: string;
84
+ }) => {
54
85
  if (chatId === selectedConversation._id) {
55
86
  setTypingUser(userId);
56
87
  }
57
88
  };
58
-
59
- const handleStopTyping = ({ userId, chatId }: { userId: string; chatId: string }) => {
89
+
90
+ const handleStopTyping = ({
91
+ userId,
92
+ chatId,
93
+ }: {
94
+ userId: string;
95
+ chatId: string;
96
+ }) => {
60
97
  if (chatId === selectedConversation._id) {
61
98
  setTypingUser((prev) => (prev === userId ? null : prev));
62
99
  }
@@ -71,35 +108,99 @@ const MessageInput = () => {
71
108
  };
72
109
  }, [socket, selectedConversation?._id]);
73
110
 
111
+ const getFileType = (mimeType: string): FileType | null => {
112
+ if (ACCEPTED_IMAGE_TYPES.includes(mimeType)) return "image";
113
+ if (ACCEPTED_VIDEO_TYPES.includes(mimeType)) return "video";
114
+ if (ACCEPTED_DOCUMENT_TYPES.includes(mimeType)) return "document";
115
+ return null;
116
+ };
117
+
118
+ const uploadToS3 = async (
119
+ file: File
120
+ ): Promise<{ url: string; name: string; size: number; type: FileType }> => {
121
+ // Get pre-signed URL from your backend
122
+ const response = await apiClient.post("api/chat/generatePresignedUrl", {
123
+ fileName: file.name,
124
+ fileType: file.type,
125
+ });
126
+
127
+ const { signedUrl, fileUrl } = await response.data();
128
+
129
+ // Upload file directly to S3 using the pre-signed URL
130
+ const uploadResponse = await fetch(signedUrl, {
131
+ method: "PUT",
132
+ body: file,
133
+ headers: {
134
+ "Content-Type": file.type,
135
+ },
136
+ });
137
+
138
+ if (!uploadResponse.ok) {
139
+ throw new Error("Upload failed");
140
+ }
141
+
142
+ return {
143
+ url: fileUrl,
144
+ name: file.name,
145
+ size: file.size,
146
+ type: getFileType(file.type),
147
+ };
148
+ };
149
+
74
150
  const handleSubmit = useCallback(
75
151
  async (e: any) => {
76
152
  e.preventDefault();
77
- if (!message || isSending) return;
153
+ // if (!message || isSending) return;
78
154
 
79
155
  setIsSending(true);
80
156
  try {
157
+ const uploadedFiles = await Promise.all(
158
+ attachments.map(async (attachment) => {
159
+ try {
160
+ const result = await uploadToS3(attachment.file);
161
+ return {
162
+ type: attachment.type,
163
+ url: result.url,
164
+ name: result.name,
165
+ size: result.size,
166
+ };
167
+ } catch (error) {
168
+ console.error(
169
+ `Error uploading file ${attachment.file.name}:`,
170
+ error
171
+ );
172
+ return null;
173
+ }
174
+ })
175
+ );
176
+
177
+ // Filter out any failed uploads
178
+ const successfulUploads = uploadedFiles.filter((file) => file !== null);
179
+
81
180
  console.log("📤 Sending message:", message);
82
- mutation.mutate({
83
- chatId: selectedConversation?.participantDetails._id,
84
- senderId: userId,
85
- message,
86
- }, {
87
- onSuccess: (data) => {
88
- console.log('Response from sendMessage:', data);
89
- // After successfully sending the message, emit the socket event
90
- socket.emit("sendMessage", {
91
- chatId: selectedConversation?._id,
92
- message,
93
- messageId:data[1]._id,
94
- senderId: userId,
95
- receiverId: selectedConversation?.participantDetails._id,
96
- });
97
- },
98
- onError: (error) => {
99
- console.error("❌ Error in sending message:", error);
181
+ mutation.mutate(
182
+ {
183
+ chatId: selectedConversation?.participantDetails._id,
184
+ senderId: userId,
185
+ message,
100
186
  },
101
- });
102
-
187
+ {
188
+ onSuccess: (data) => {
189
+ console.log("Response from sendMessage:", data);
190
+ // After successfully sending the message, emit the socket event
191
+ socket.emit("sendMessage", {
192
+ chatId: selectedConversation?._id,
193
+ message,
194
+ messageId: data[1]._id,
195
+ senderId: userId,
196
+ receiverId: selectedConversation?.participantDetails._id,
197
+ });
198
+ },
199
+ onError: (error) => {
200
+ console.error("❌ Error in sending message:", error);
201
+ },
202
+ }
203
+ );
103
204
  } catch (error) {
104
205
  console.error("❌ Error sending message:", error);
105
206
  } finally {
@@ -110,34 +211,242 @@ const MessageInput = () => {
110
211
  [message, selectedConversation, userId, isSending]
111
212
  );
112
213
 
214
+ const [showAttachmentOptions, setShowAttachmentOptions] = useState(false);
215
+ const fileInputRef = useRef<HTMLInputElement>(null);
216
+
217
+ useEffect(() => {
218
+ return () => {
219
+ attachments.forEach((attachment) => {
220
+ URL.revokeObjectURL(attachment.previewUrl);
221
+ });
222
+ };
223
+ }, [attachments]);
224
+
225
+ const handleAttachmentClick = () => {
226
+ setShowAttachmentOptions(!showAttachmentOptions);
227
+ };
228
+
229
+ const handleFileSelect = (type: FileType) => {
230
+ if (fileInputRef.current) {
231
+ fileInputRef.current.accept = getAcceptString(type);
232
+ fileInputRef.current.click();
233
+ }
234
+ setShowAttachmentOptions(false);
235
+ };
236
+
237
+ const getAcceptString = (type: FileType): string => {
238
+ switch (type) {
239
+ case "image":
240
+ return ACCEPTED_IMAGE_TYPES.join(",");
241
+ case "video":
242
+ return ACCEPTED_VIDEO_TYPES.join(",");
243
+ case "document":
244
+ return ACCEPTED_DOCUMENT_TYPES.join(",");
245
+ default:
246
+ return "*";
247
+ }
248
+ };
249
+
250
+ const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
251
+ const files = e.target.files;
252
+ if (!files || files.length === 0) return;
253
+
254
+ const newAttachments: Attachment[] = [];
255
+
256
+ for (let i = 0; i < files.length; i++) {
257
+ const file = files[i];
258
+ const fileType = getFileType(file.type);
259
+
260
+ // Validate file type
261
+ if (!fileType) {
262
+ console.error(`Unsupported file type: ${file.type}`);
263
+ continue;
264
+ }
265
+
266
+ // Validate file size
267
+ if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
268
+ console.error(`File too large: ${file.name}`);
269
+ continue;
270
+ }
271
+
272
+ // Create preview URL
273
+ const previewUrl =
274
+ fileType === "document"
275
+ ? URL.createObjectURL(new Blob([""], { type: "application/pdf" })) // Placeholder for documents
276
+ : URL.createObjectURL(file);
277
+
278
+ newAttachments.push({
279
+ file,
280
+ type: fileType,
281
+ previewUrl,
282
+ });
283
+ }
284
+
285
+ setAttachments((prev) => [...prev, ...newAttachments]);
286
+ if (fileInputRef.current) {
287
+ fileInputRef.current.value = "";
288
+ }
289
+ };
290
+
291
+ const removeAttachment = (index: number) => {
292
+ setAttachments((prev) => {
293
+ const newAttachments = [...prev];
294
+ URL.revokeObjectURL(newAttachments[index].previewUrl);
295
+ newAttachments.splice(index, 1);
296
+ return newAttachments;
297
+ });
298
+ };
299
+
113
300
  return (
114
- <form className="chatMessageInputform" onSubmit={handleSubmit}>
115
- <div className="chatMessageInputdiv">
116
- <input
117
- type="text"
118
- className="chatMessageInput"
119
- placeholder="Send a message"
120
- value={message}
121
- onChange={(e) => setMessage(e.target.value)} // Update message state as the user types
122
- />
123
- <button type="submit" className="chatMessageInputSubmit">
124
- <img width={10} height={10} src={paperplane} alt="send" />
125
- {/* {loading ? <div className='loading loading-spinner'></div> : <PaperPlaneRight />} Show loading spinner if loading */}
126
- </button>
127
- </div>
128
-
129
- {typingUser && typingUser !== userId && typingUser === selectedConversation?.participantDetails?._id && !isSending && (
130
- <div className="typingIndicator">
131
- <div className="loader">
132
- <div className="ball" />
133
- <div className="ball" />
134
- <div className="ball" />
135
- typing
136
- </div>
137
- </div>
138
- )}
301
+ <div className="message-input-container">
302
+ {/* Preview area for attachments */}
303
+ {attachments.length > 0 && (
304
+ <div className="attachments-preview">
305
+ {attachments.map((attachment, index) => (
306
+ <FilePreview
307
+ key={index}
308
+ file={attachment.file}
309
+ type={attachment.type}
310
+ previewUrl={attachment.previewUrl}
311
+ onRemove={() => removeAttachment(index)}
312
+ />
313
+ ))}
314
+ </div>
315
+ )}
139
316
 
140
- </form>
317
+ <form className="chatMessageInputform" onSubmit={handleSubmit}>
318
+ <div className="chatMessageInputdiv">
319
+ {/* Hidden file input */}
320
+ <input
321
+ type="file"
322
+ ref={fileInputRef}
323
+ style={{ display: "none" }}
324
+ onChange={handleFileChange}
325
+ multiple
326
+ />
327
+
328
+ {/* Attachment button and options */}
329
+ <div
330
+ className="attachment-container"
331
+ style={{ position: "relative" }}
332
+ >
333
+ <button
334
+ type="button"
335
+ className="attachment-button"
336
+ onClick={handleAttachmentClick}
337
+ style={{
338
+ background: "none",
339
+ border: "none",
340
+ cursor: "pointer",
341
+ padding: "8px",
342
+ }}
343
+ >
344
+ <div className="attachment-icon">
345
+ <svg
346
+ xmlns="http://www.w3.org/2000/svg"
347
+ width="18"
348
+ height="18"
349
+ viewBox="0 0 24 24"
350
+ fill="none"
351
+ stroke="currentColor"
352
+ strokeWidth="2"
353
+ strokeLinecap="round"
354
+ strokeLinejoin="round"
355
+ >
356
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
357
+ </svg>
358
+ </div>
359
+ </button>
360
+
361
+ {showAttachmentOptions && (
362
+ <div className="attachment-options">
363
+ <button type="button" onClick={() => handleFileSelect("image")}>
364
+ <div className="icon">
365
+ <svg
366
+ xmlns="http://www.w3.org/2000/svg"
367
+ width="18"
368
+ height="18"
369
+ viewBox="0 0 24 24"
370
+ fill="none"
371
+ stroke="currentColor"
372
+ strokeWidth="2"
373
+ strokeLinecap="round"
374
+ strokeLinejoin="round"
375
+ >
376
+ <rect
377
+ x="3"
378
+ y="3"
379
+ width="18"
380
+ height="18"
381
+ rx="2"
382
+ ry="2"
383
+ ></rect>
384
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
385
+ <polyline points="21 15 16 10 5 21"></polyline>
386
+ </svg>
387
+ </div>
388
+ <span>Photos & videos</span>
389
+ </button>
390
+ <button
391
+ type="button"
392
+ onClick={() => handleFileSelect("document")}
393
+ >
394
+ <div className="icon">
395
+ <svg
396
+ xmlns="http://www.w3.org/2000/svg"
397
+ width="18"
398
+ height="18"
399
+ viewBox="0 0 24 24"
400
+ fill="none"
401
+ stroke="currentColor"
402
+ strokeWidth="2"
403
+ strokeLinecap="round"
404
+ strokeLinejoin="round"
405
+ >
406
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
407
+ <polyline points="14 2 14 8 20 8"></polyline>
408
+ <line x1="16" y1="13" x2="8" y2="13"></line>
409
+ <line x1="16" y1="17" x2="8" y2="17"></line>
410
+ <polyline points="10 9 9 9 8 9"></polyline>
411
+ </svg>
412
+ </div>
413
+ <span>Document</span>
414
+ </button>
415
+ </div>
416
+ )}
417
+ </div>
418
+
419
+ <input
420
+ type="text"
421
+ className="chatMessageInput"
422
+ placeholder="Send a message"
423
+ value={message}
424
+ onChange={(e) => setMessage(e.target.value)}
425
+ />
426
+ <button
427
+ type="submit"
428
+ className="chatMessageInputSubmit"
429
+ disabled={isSending}
430
+ >
431
+ <img width={10} height={10} src={paperplane} alt="send" />
432
+ </button>
433
+ </div>
434
+
435
+ {typingUser &&
436
+ typingUser !== userId &&
437
+ typingUser === selectedConversation?.participantDetails?._id &&
438
+ !isSending && (
439
+ <div className="typingIndicator">
440
+ <div className="loader">
441
+ <div className="ball" />
442
+ <div className="ball" />
443
+ <div className="ball" />
444
+ typing
445
+ </div>
446
+ </div>
447
+ )}
448
+ </form>
449
+ </div>
141
450
  );
142
451
  };
143
452