@pubuduth-aplicy/chat-ui 2.1.51 → 2.1.52

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.51",
3
+ "version": "2.1.52",
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": "",
@@ -1,77 +1,198 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- /* eslint-disable @typescript-eslint/no-unused-vars */
3
-
4
2
  import { MessageStatus } from "../../types/type";
5
3
  import { useChatContext } from "../../providers/ChatProvider";
6
- import useChatUIStore from "../../stores/Zustant";
4
+ // import { saveAs } from 'file-saver';
5
+ import { useEffect, useState } from "react";
6
+ import { FileType } from "../common/FilePreview";
7
7
 
8
- // import { useAuthContext } from "../../context/AuthContext";
9
- // import { extractTime } from "../../utils/extractTime";
10
- // import useConversation from "../../zustand/useConversation";
11
8
  interface MessageProps {
12
9
  message: {
10
+ _id?: string;
13
11
  senderId: string;
14
12
  message: string;
15
13
  status: MessageStatus;
16
- createdAt:any;
14
+ createdAt: any;
15
+ media?: {
16
+ type: FileType;
17
+ url: string;
18
+ name: string;
19
+ size: number;
20
+ uploadProgress?: number;
21
+ uploadError: string | null;
22
+ }[];
23
+ isUploading?: boolean;
17
24
  };
18
25
  }
19
26
 
20
27
  const Message = ({ message }: MessageProps) => {
28
+ const { userId } = useChatContext();
29
+ const fromMe = message.senderId === userId;
30
+ const timestamp = fromMe ? "timestamp_outgoing" : "timestamp_incomeing";
31
+ const alignItems = fromMe ? "outgoing" : "incoming";
32
+ const [localStatus, setLocalStatus] = useState(message.status);
33
+ const [downloadingIndex, setDownloadingIndex] = useState<number | null>(null);
21
34
 
22
- const { userId } = useChatContext();
23
- const fromMe = message.senderId === userId;
24
- const timestamp = fromMe ? "timestamp_outgoing" : "timestamp_incomeing";
25
- const alignItems = fromMe ? "outgoing" : "incoming";
35
+ useEffect(() => {
36
+ setLocalStatus(message.status);
37
+ }, [message.status]);
26
38
 
27
- const date = new Date(message.createdAt);
28
- const hours = date.getUTCHours();
29
- const minutes = date.getUTCMinutes();
30
- const seconds = date.getUTCSeconds();
39
+ // const handleDownload = (url: string, name: string) => {
40
+ // saveAs(url, name);
41
+ // };
31
42
 
32
- // Format the time as HH:mm:ss (24-hour format)
33
- const time = `${hours}.${minutes}`;
43
+ const getStatusIcon = () => {
44
+ if (!fromMe) return null;
45
+
46
+ if (message.isUploading || message.status === 'sending') {
47
+ return <span className="message-status uploading">🔄</span>;
48
+ }
49
+
50
+ if (message.status === 'failed') {
51
+ return <span className="message-status failed">❌</span>;
52
+ }
53
+
54
+ switch (localStatus) {
55
+ case 'sending':
56
+ return <span className="message-status sending">🔄</span>;
57
+ case 'sent':
58
+ return <span className="message-status sent">✓</span>;
59
+ case 'delivered':
60
+ return <span className="message-status delivered">✓✓</span>;
61
+ case 'read':
62
+ return <span className="message-status read">✓✓</span>;
63
+ default:
64
+ return null;
65
+ }
66
+ };
34
67
 
35
- const getStatusIcon = () => {
36
- if (!fromMe) return null;
37
-
38
- switch (message.status) {
39
- case 'sent':
40
- return <span className="message-status sent">✓</span>;
41
- case 'delivered':
42
- return <span className="message-status delivered">✓✓</span>;
43
- case 'read':
44
- return <span className="message-status read">✓✓ (blue)</span>;
45
- default:
46
- return null;
47
- }
48
- };
68
+ const handleDownload = async (url: string, name: string, index: number) => {
69
+ setDownloadingIndex(index);
70
+ try {
71
+ const response = await fetch(url);
72
+ const blob = await response.blob();
73
+ const link = document.createElement('a');
74
+ link.href = URL.createObjectURL(blob);
75
+ link.download = name;
76
+ link.click();
77
+ URL.revokeObjectURL(link.href);
78
+ } catch (error) {
79
+ console.error("Download failed:", error);
80
+ } finally {
81
+ setDownloadingIndex(null);
82
+ }
83
+ };
49
84
 
50
- return (
51
- <>
52
- {/* <div className="w-max grid">
53
- <div className="px-3.5 py-2 bg-gray-100 rounded-3xl rounded-tl-none justify-start items-center gap-3 inline-flex">
54
- <h5 className="text-gray-900 text-sm font-normal leading-snug">{message.message}</h5>
55
- </div>
56
- <div className="justify-end items-center inline-flex mb-2.5">
57
- <h6 className="text-gray-500 text-xs font-normal leading-4 py-1">05:14 PM</h6>
58
- </div>
59
- </div> */}
85
+ const renderMedia = () => {
86
+ if (!message.media || message.media.length === 0) return null;
87
+
88
+ return (
89
+ <div className={`media-grid ${message.media.length > 1 ? 'multi-media' : 'single-media'}`}>
90
+ {message.media.map((media, index) => (
91
+ <div key={index} className="media-item">
92
+
93
+ {/* Progress indicator */}
94
+ {(media.uploadProgress !== undefined && media.uploadProgress < 100) && (
95
+ <div className="circular-progress-container">
96
+ <div className="media-preview-background">
97
+ <img
98
+ src={media.url}
99
+ alt={media.name}
100
+ className="blurred-preview"
101
+ />
102
+ </div>
103
+ <div className="circular-progress">
104
+ <svg className="circular-progress-svg" viewBox="0 0 36 36">
105
+ <path
106
+ className="circular-progress-track"
107
+ d="M18 2.0845
108
+ a 15.9155 15.9155 0 0 1 0 31.831
109
+ a 15.9155 15.9155 0 0 1 0 -31.831"
110
+ />
111
+ <path
112
+ className="circular-progress-bar"
113
+ strokeDasharray={`${media.uploadProgress}, 100`}
114
+ d="M18 2.0845
115
+ a 15.9155 15.9155 0 0 1 0 31.831
116
+ a 15.9155 15.9155 0 0 1 0 -31.831"
117
+ />
118
+ </svg>
119
+ <span className="circular-progress-text">
120
+ {media.uploadProgress}%
121
+ </span>
122
+ </div>
123
+ </div>
124
+ )}
125
+
126
+ {/* Error state */}
127
+ {media.uploadError && (
128
+ <div className="upload-error">
129
+ <span>⚠️ Upload failed</span>
130
+ </div>
131
+ )}
60
132
 
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>
133
+ {/* Actual media (shown when upload complete) */}
134
+ {(!media.uploadProgress || media.uploadProgress >= 100) && !media.uploadError && (
135
+ <>
136
+ {media.type === 'image' ? (
137
+ <img
138
+ src={media.url}
139
+ alt={media.name}
140
+ className="media-content"
141
+ onClick={() => window.open(media.url, '_blank')}
142
+ />
143
+ ) : media.type === 'video' ? (
144
+ <video controls className="media-content">
145
+ <source src={media.url} type={`video/${media.url.split('.').pop()}`} />
146
+ </video>
147
+ ) : (
148
+ <div className="document-preview">
149
+ <div className="file-icon">
150
+ {media.type === 'document' && '📄'}
151
+ </div>
152
+ <div className="file-info">
153
+ <span className="file-name">{media.name}</span>
154
+ <span className="file-size">{(media.size / 1024).toFixed(1)} KB</span>
155
+ </div>
156
+
157
+ </div>
158
+ )}
159
+ <button
160
+ className="download-btn"
161
+ onClick={(e) => {
162
+ e.stopPropagation();
163
+ handleDownload(media.url, media.name, index);
164
+ }}
165
+ title="Download"
166
+ disabled={downloadingIndex === index}
167
+ >
168
+ {downloadingIndex === index ? <span className="loader" /> : '⬇️'}
169
+ </button>
170
+ </>
171
+ )}
172
+ </div>
173
+ ))}
67
174
  </div>
175
+ );
176
+ };
177
+
178
+ return (
179
+ <div className="chat-container">
180
+ <div className={`message-row ${alignItems}`}>
181
+ <div className="bubble-container">
182
+ {(message.message || (message.media && message.media.length > 0)) && (
183
+ <div className="chat-bubble compact-bubble">
184
+ {renderMedia()}
185
+ {message.message && <div className="message-text">{message.message}</div>}
186
+ </div>
187
+ )}
188
+ <div className={`${timestamp}`}>
189
+ {new Date(message.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
190
+ <span className="status-icon">{getStatusIcon()}</span>
191
+ </div>
192
+ </div>
68
193
  </div>
69
194
  </div>
70
- </div>
71
-
72
-
73
- </>
74
- )
75
- }
195
+ );
196
+ };
76
197
 
77
- export default Message
198
+ export default Message;
@@ -46,7 +46,7 @@ const MessageContainer = () => {
46
46
  <div className='chatMessageContainer'>
47
47
 
48
48
  {!selectedConversation ? (
49
- <NoChatSelected />
49
+ <EmptyInbox />
50
50
  ) : (
51
51
  <>
52
52
  <div className="chatMessageContainerInner">
@@ -97,16 +97,86 @@ const MessageContainer = () => {
97
97
 
98
98
  export default MessageContainer;
99
99
 
100
- const NoChatSelected = () => {
101
- // const { authUser } = useAuthContext();
102
-
100
+ interface EmptyInboxProps {
101
+ title?: string;
102
+ description?: string;
103
+ }
104
+
105
+ const EmptyInbox: React.FC<EmptyInboxProps> = ({
106
+ title = "Ah, a fresh new inbox",
107
+ description = "You haven't started any conversations yet, but when you do, you'll find them here.",
108
+ }) => {
103
109
  return (
104
- <div className='chatMessageContainerNoChat'>
105
- <div className='chatMessageContainerNoChat_div'>
106
- <p>Welcome </p> {/* Safely access username */}
107
- <p>Select a chat to start messaging</p>
108
- {/* <Chat className='text-3xl md:text-6xl text-center' /> */}
109
- </div>
110
+ <div className="flex flex-col items-center justify-center h-full p-6 text-center">
111
+ <div className="w-48 h-48 mb-6 relative">
112
+ <svg
113
+ viewBox="0 0 200 200"
114
+ fill="none"
115
+ xmlns="http://www.w3.org/2000/svg"
116
+ className="w-full h-full"
117
+ >
118
+ <line x1="100" y1="100" x2="100" y2="160" stroke="black" strokeWidth="2" />
119
+ <line x1="40" y1="160" x2="160" y2="160" stroke="black" strokeWidth="1" />
120
+ <path
121
+ d="M70 160C75 150 80 155 85 160"
122
+ stroke="black"
123
+ strokeWidth="1"
124
+ fill="none"
125
+ />
126
+ <path
127
+ d="M115 160C120 150 125 155 130 160"
128
+ stroke="black"
129
+ strokeWidth="1"
130
+ fill="none"
131
+ />
132
+ <rect
133
+ x="70"
134
+ y="80"
135
+ width="60"
136
+ height="30"
137
+ stroke="black"
138
+ strokeWidth="1"
139
+ fill="white"
140
+ />
141
+ <path
142
+ d="M70 80C70 65 130 65 130 80"
143
+ stroke="black"
144
+ strokeWidth="1"
145
+ fill="none"
146
+ />
147
+ <rect
148
+ x="70"
149
+ y="80"
150
+ width="60"
151
+ height="20"
152
+ stroke="black"
153
+ strokeWidth="1"
154
+ fill="white"
155
+ />
156
+ <path d="M120 90H125V95H120V90Z" fill="#10B981" />
157
+ <path
158
+ d="M120 90H125V95H120V90Z"
159
+ stroke="black"
160
+ strokeWidth="0.5"
161
+ />
162
+ <path d="M125 92L130 87" stroke="#10B981" strokeWidth="1" />
163
+ <path d="M125 92L130 97" stroke="#10B981" strokeWidth="1" />
164
+ <path
165
+ d="M130 60C140 55 150 65 140 70"
166
+ stroke="black"
167
+ strokeWidth="1"
168
+ strokeDasharray="2"
169
+ fill="none"
170
+ />
171
+ <text x="140" y="60" fontSize="12" fill="black">
172
+
173
+ </text>
174
+ <circle cx="85" cy="175" r="5" fill="#10B981" />
175
+ <circle cx="115" cy="175" r="5" fill="#10B981" />
176
+ </svg>
110
177
  </div>
178
+ <h3 className="text-xl font-medium text-gray-800 mb-2">{title}</h3>
179
+ <p className="text-gray-500 max-w-sm">{description}</p>
180
+ </div>
111
181
  );
112
- };
182
+ };
@@ -8,20 +8,11 @@ import paperplane from "../../assets/icons8-send-50.png";
8
8
  // import useSendMessage from '../../hooks/useSendMessage'; // Importing the useSendMessage hook
9
9
  import { FilePreview, FileType } from "../common/FilePreview";
10
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
- ];
11
+ import { MessageStatus } from "../../types/type";
12
+ const MAX_FILE_SIZE_MB = 25; // 10MB max file size
13
+ const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp','video/mp4', 'video/webm', 'video/ogg'];
14
+ const ACCEPTED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg'];
15
+ const ACCEPTED_DOCUMENT_TYPES = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain'];
25
16
 
26
17
  interface Attachment {
27
18
  file: File;
@@ -35,15 +26,26 @@ const MessageInput = () => {
35
26
  const apiClient = getApiClient();
36
27
  const { socket } = useChatContext();
37
28
  const { userId } = useChatContext();
38
- const { selectedConversation } = useChatUIStore();
39
- const [message, setMessage] = useState(""); // State for storing the message input
40
- // const { mutate: sendMessage } = useMessageMutation();
41
- const mutation = useMessageMutation(); // useMutation hook to send message
29
+ const { selectedConversation,setMessages } = useChatUIStore();
30
+ const [message, setMessage] = useState("");
31
+ const mutation = useMessageMutation();
42
32
  const [typingUser, setTypingUser] = useState<string | null>(null);
43
33
  const [isSending, setIsSending] = useState(false);
44
34
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
45
- const [isTyping, setIsTyping] = useState(false); // Track if the user is typing
35
+ const [isTyping, setIsTyping] = useState(false);
46
36
  const [attachments, setAttachments] = useState<Attachment[]>([]);
37
+ const [showAttachmentOptions, setShowAttachmentOptions] = useState(false);
38
+ const fileInputRef = useRef<HTMLInputElement>(null);
39
+ const attachmentsRef = useRef<Attachment[]>([]);
40
+ const [tempMessageId, setTempMessageId] = useState<string | null>(null);
41
+
42
+ const generateTempId = () => `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
43
+
44
+ // Function to auto-resize the textarea
45
+ const autoResizeTextarea = (element: HTMLTextAreaElement) => {
46
+ element.style.height = "auto"
47
+ element.style.height = Math.min(150, element.scrollHeight) + "px"
48
+ }
47
49
 
48
50
  useEffect(() => {
49
51
  if (selectedConversation?._id) {
@@ -72,28 +74,17 @@ const MessageInput = () => {
72
74
  return () => clearTimeout(typingTimeout);
73
75
  }, [message, socket, selectedConversation?._id, userId]);
74
76
 
77
+
75
78
  useEffect(() => {
76
79
  if (!socket || !selectedConversation?._id) return;
77
80
 
78
- const handleTyping = ({
79
- userId,
80
- chatId,
81
- }: {
82
- userId: string;
83
- chatId: string;
84
- }) => {
81
+ const handleTyping = ({ userId, chatId }: { userId: string; chatId: string }) => {
85
82
  if (chatId === selectedConversation._id) {
86
83
  setTypingUser(userId);
87
84
  }
88
85
  };
89
86
 
90
- const handleStopTyping = ({
91
- userId,
92
- chatId,
93
- }: {
94
- userId: string;
95
- chatId: string;
96
- }) => {
87
+ const handleStopTyping = ({ userId, chatId }: { userId: string; chatId: string }) => {
97
88
  if (chatId === selectedConversation._id) {
98
89
  setTypingUser((prev) => (prev === userId ? null : prev));
99
90
  }
@@ -109,114 +100,213 @@ const MessageInput = () => {
109
100
  }, [socket, selectedConversation?._id]);
110
101
 
111
102
  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";
103
+ if (ACCEPTED_IMAGE_TYPES.includes(mimeType)) return 'image';
104
+ if (ACCEPTED_VIDEO_TYPES.includes(mimeType)) return 'video';
105
+ if (ACCEPTED_DOCUMENT_TYPES.includes(mimeType)) return 'document';
115
106
  return null;
116
107
  };
117
108
 
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
- },
109
+ const uploadToS3 = async (file: File, onProgress?:(progress:number)=> void): Promise<{ url: string, name: string, size: number, type: FileType }> => {
110
+ const response = await apiClient.post('api/chat/generatePresignedUrl', {
111
+ fileName: file.name,
112
+ fileType: file.type,
113
+ }
114
+ );
115
+
116
+ const { signedUrl, fileUrl } = await response.data;
117
+
118
+ // const uploadResponse = await fetch(signedUrl, {
119
+ // method: 'PUT',
120
+ // body: file,
121
+ // headers: {
122
+ // 'Content-Type': file.type,
123
+ // // 'x-amz-acl': 'public-read'
124
+ // },
125
+ // });
126
+
127
+ const xhr = new XMLHttpRequest();
128
+ xhr.open('PUT', signedUrl, true);
129
+ xhr.setRequestHeader('Content-Type', file.type);
130
+
131
+ return new Promise((resolve, reject) => {
132
+ xhr.upload.onprogress = (event) => {
133
+ if (event.lengthComputable && onProgress) {
134
+ const progress = Math.round((event.loaded / event.total) * 100);
135
+ onProgress(progress);
136
+ }
137
+ };
138
+
139
+ xhr.onload = () => {
140
+ if (xhr.status >= 200 && xhr.status < 300) {
141
+ resolve({
142
+ url: fileUrl,
143
+ name: file.name,
144
+ size: file.size,
145
+ type: getFileType(file.type),
146
+ });
147
+ } else {
148
+ reject(new Error('Upload failed'));
149
+ }
150
+ };
151
+
152
+ xhr.onerror = () => reject(new Error('Upload failed'));
153
+ xhr.send(file);
136
154
  });
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
- };
155
+
156
+ // if (!uploadResponse.ok) {
157
+ // throw new Error('Upload failed');
158
+ // }
159
+
160
+ // return {
161
+ // url: fileUrl,
162
+ // name: file.name,
163
+ // size: file.size,
164
+ // type: getFileType(file.type),
165
+ // };
148
166
  };
149
167
 
150
168
  const handleSubmit = useCallback(
151
169
  async (e: any) => {
152
170
  e.preventDefault();
153
- // if (!message || isSending) return;
154
-
171
+ if (!message && attachmentsRef.current.length === 0 || isSending) return;
155
172
  setIsSending(true);
173
+
174
+ const tempId = generateTempId();
175
+ setTempMessageId(tempId);
176
+
177
+ const optimisticMessage = {
178
+ _id: tempId,
179
+ text: message, // Added text property to match the expected type
180
+ message: message,
181
+ senderId: userId,
182
+ status: 'sending' as MessageStatus,
183
+ createdAt: new Date().toISOString(),
184
+ media: attachmentsRef.current.map(att => ({
185
+ type: att.type,
186
+ url: att.previewUrl,
187
+ name: att.file.name,
188
+ size: att.file.size,
189
+ uploadProgress: 0,
190
+ uploadError: "",
191
+ })),
192
+ isUploading: true,
193
+ isOptimistic: true
194
+ };
195
+
196
+ setMessages(prev => [...prev, optimisticMessage]);
197
+
156
198
  try {
199
+
157
200
  const uploadedFiles = await Promise.all(
158
- attachments.map(async (attachment) => {
201
+ attachmentsRef.current.map(async (attachment,index) => {
159
202
  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
- };
203
+ const result = await uploadToS3(attachment.file,(progress)=>{
204
+ setMessages(prev => prev.map(msg =>{
205
+ if(msg._id === tempId){
206
+ const updatedMedia = [...msg.media!];
207
+ updatedMedia[index] = {
208
+ ...updatedMedia[index],
209
+ uploadProgress: progress
210
+ };
211
+ return {
212
+ ...msg,
213
+ media: updatedMedia
214
+ };
215
+ }
216
+ return msg;
217
+ }))
218
+ });
219
+ console.log("Uploaded file:", result);
220
+ return result;
221
+ // return {
222
+ // type: attachment.type,
223
+ // url: result.url,
224
+ // name: result.name,
225
+ // size: result.size
226
+ // };
167
227
  } catch (error) {
168
- console.error(
169
- `Error uploading file ${attachment.file.name}:`,
170
- error
171
- );
228
+ console.error(`Error uploading file ${attachment.file.name}:`, error);
229
+ setMessages(prev => prev.map(msg => {
230
+ if(msg._id === tempId){
231
+ const updatedMedia = [...msg.media!];
232
+ updatedMedia[index] = {
233
+ ...updatedMedia[index],
234
+ uploadError: "Upload failed"
235
+ };
236
+ return {
237
+ ...msg,
238
+ media: updatedMedia
239
+ };
240
+ }
241
+ return msg;
242
+ }))
172
243
  return null;
173
244
  }
174
245
  })
175
246
  );
176
-
177
- // Filter out any failed uploads
178
- const successfulUploads = uploadedFiles.filter((file) => file !== null);
179
-
180
- console.log("📤 Sending message:", message);
181
- mutation.mutate(
182
- {
183
- chatId: selectedConversation?.participantDetails._id,
184
- senderId: userId,
185
- message,
247
+
248
+
249
+ const successfulUploads = uploadedFiles.filter(file => file !== null);
250
+
251
+ console.log("📤 Sending message:", successfulUploads);
252
+ mutation.mutate({
253
+ chatId: selectedConversation?.participantDetails._id,
254
+ senderId: userId,
255
+ message,
256
+ attachments: successfulUploads,
257
+ }, {
258
+ onSuccess: (data) => {
259
+ console.log('Response from sendMessage:', data);
260
+
261
+ socket.emit("sendMessage", {
262
+ chatId: selectedConversation?._id,
263
+ message,
264
+ messageId: data[1]._id,
265
+ attachments: successfulUploads,
266
+ senderId: userId,
267
+ receiverId: selectedConversation?.participantDetails._id,
268
+ });
269
+ setMessages(prev => {
270
+ console.log("Removing optimistic message:", prev);
271
+
272
+ // Definitely remove the optimistic message
273
+ const filtered = prev.filter(msg => msg._id !== tempMessageId);
274
+ // Add the real message from server
275
+ console.log("Adding real message:", filtered);
276
+
277
+ return [...filtered, {
278
+ ...data[1],
279
+ isUploading: false,
280
+ isOptimistic: false}
281
+ ];
282
+ });
186
283
  },
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
- );
284
+ onError: (error) => {
285
+ console.error("❌ Error in sending message:", error);
286
+ setMessages(prev => prev.map(msg =>
287
+ msg._id === tempId ? { ...msg, status: 'failed' } : msg
288
+ ));
289
+ },
290
+ });
291
+
204
292
  } catch (error) {
205
293
  console.error("❌ Error sending message:", error);
294
+ setMessages(prev => prev.map(msg =>
295
+ msg._id === tempId ? { ...msg, status: 'failed' } : msg
296
+ ));
206
297
  } finally {
207
298
  setIsSending(false);
208
299
  setMessage("");
300
+ setAttachments([]);
301
+ setTempMessageId(null);
209
302
  }
210
303
  },
211
304
  [message, selectedConversation, userId, isSending]
212
305
  );
213
306
 
214
- const [showAttachmentOptions, setShowAttachmentOptions] = useState(false);
215
- const fileInputRef = useRef<HTMLInputElement>(null);
216
-
217
307
  useEffect(() => {
218
308
  return () => {
219
- attachments.forEach((attachment) => {
309
+ attachments.forEach(attachment => {
220
310
  URL.revokeObjectURL(attachment.previewUrl);
221
311
  });
222
312
  };
@@ -236,17 +326,22 @@ const MessageInput = () => {
236
326
 
237
327
  const getAcceptString = (type: FileType): string => {
238
328
  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(",");
329
+ case 'image':
330
+ return ACCEPTED_IMAGE_TYPES.join(',');
331
+ case 'video':
332
+ return ACCEPTED_VIDEO_TYPES.join(',');
333
+ case 'document':
334
+ return ACCEPTED_DOCUMENT_TYPES.join(',');
245
335
  default:
246
- return "*";
336
+ return '*';
247
337
  }
248
338
  };
249
339
 
340
+
341
+ useEffect(() => {
342
+ attachmentsRef.current = attachments;
343
+ }, [attachments]);
344
+
250
345
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
251
346
  const files = e.target.files;
252
347
  if (!files || files.length === 0) return;
@@ -257,39 +352,37 @@ const MessageInput = () => {
257
352
  const file = files[i];
258
353
  const fileType = getFileType(file.type);
259
354
 
260
- // Validate file type
261
355
  if (!fileType) {
262
356
  console.error(`Unsupported file type: ${file.type}`);
263
357
  continue;
264
358
  }
265
359
 
266
- // Validate file size
267
360
  if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
268
361
  console.error(`File too large: ${file.name}`);
269
362
  continue;
270
363
  }
271
364
 
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);
365
+
366
+ const previewUrl = fileType === 'document'
367
+ ? URL.createObjectURL(new Blob([''], { type: 'application/pdf' })) // Placeholder for documents
368
+ : URL.createObjectURL(file);
277
369
 
278
370
  newAttachments.push({
279
371
  file,
280
372
  type: fileType,
281
- previewUrl,
373
+ previewUrl
282
374
  });
283
375
  }
284
376
 
285
- setAttachments((prev) => [...prev, ...newAttachments]);
377
+ setAttachments(prev => [...prev, ...newAttachments]);
286
378
  if (fileInputRef.current) {
287
- fileInputRef.current.value = "";
379
+ fileInputRef.current.value = '';
288
380
  }
289
381
  };
382
+
290
383
 
291
384
  const removeAttachment = (index: number) => {
292
- setAttachments((prev) => {
385
+ setAttachments(prev => {
293
386
  const newAttachments = [...prev];
294
387
  URL.revokeObjectURL(newAttachments[index].previewUrl);
295
388
  newAttachments.splice(index, 1);
@@ -299,154 +392,120 @@ const MessageInput = () => {
299
392
 
300
393
  return (
301
394
  <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
- )}
316
-
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)}
395
+ {/* Preview area for attachments */}
396
+ {attachments.length > 0 && (
397
+ <div className="attachments-preview">
398
+ {attachments.map((attachment, index) => (
399
+ <FilePreview
400
+ key={index}
401
+ file={attachment.file}
402
+ type={attachment.type}
403
+ previewUrl={attachment.previewUrl}
404
+ onRemove={() => removeAttachment(index)}
425
405
  />
406
+ ))}
407
+ </div>
408
+ )}
409
+
410
+ <form className="chatMessageInputform" onSubmit={handleSubmit}>
411
+ <div className="chatMessageInputdiv">
412
+ {/* Hidden file input */}
413
+ <input
414
+ type="file"
415
+ ref={fileInputRef}
416
+ style={{ display: 'none' }}
417
+ onChange={handleFileChange}
418
+ multiple
419
+ />
420
+
421
+ {/* Attachment button and options */}
422
+ <div className="attachment-container" style={{ position: 'relative' }}>
426
423
  <button
427
- type="submit"
428
- className="chatMessageInputSubmit"
429
- disabled={isSending}
424
+ type="button"
425
+ className="attachment-button"
426
+ onClick={handleAttachmentClick}
427
+ style={{
428
+ background: 'none',
429
+ border: 'none',
430
+ cursor: 'pointer',
431
+ padding: '8px',
432
+ }}
430
433
  >
431
- <img width={10} height={10} src={paperplane} alt="send" />
434
+ <div className="attachment-icon">
435
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
436
+ <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>
437
+ </svg>
438
+ </div>
432
439
  </button>
433
- </div>
434
440
 
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>
441
+ {showAttachmentOptions && (
442
+ <div className="attachment-options">
443
+ <button
444
+ type="button"
445
+ onClick={() => handleFileSelect('image')}
446
+ >
447
+ <div className="icon">
448
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
449
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
450
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
451
+ <polyline points="21 15 16 10 5 21"></polyline>
452
+ </svg>
453
+ </div>
454
+ <span>Photos & videos</span>
455
+ </button>
456
+ <button
457
+ type="button"
458
+ onClick={() => handleFileSelect('document')}
459
+ >
460
+ <div className="icon">
461
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
462
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
463
+ <polyline points="14 2 14 8 20 8"></polyline>
464
+ <line x1="16" y1="13" x2="8" y2="13"></line>
465
+ <line x1="16" y1="17" x2="8" y2="17"></line>
466
+ <polyline points="10 9 9 9 8 9"></polyline>
467
+ </svg>
468
+ </div>
469
+ <span>Document</span>
470
+ </button>
446
471
  </div>
447
472
  )}
448
- </form>
449
- </div>
473
+ </div>
474
+
475
+ <textarea
476
+ className="chatMessageInput"
477
+ placeholder="Send a message"
478
+ value={message}
479
+ onChange={(e) => {
480
+ setMessage(e.target.value)
481
+ autoResizeTextarea(e.target)
482
+ }}
483
+ rows={1}
484
+ style={{ resize: "none" }}
485
+ onKeyDown={(e) => {
486
+ if (e.key === "Enter" && !e.shiftKey) {
487
+ e.preventDefault()
488
+ handleSubmit(e)
489
+ }
490
+ }}
491
+ />
492
+ <button type="submit" className="chatMessageInputSubmit" disabled={isSending}>
493
+ <img width={10} height={10} src={paperplane} alt="send" />
494
+ </button>
495
+ </div>
496
+
497
+ {typingUser && typingUser !== userId && typingUser === selectedConversation?.participantDetails?._id && !isSending && (
498
+ <div className="typingIndicator">
499
+ <div className="loader">
500
+ <div className="ball" />
501
+ <div className="ball" />
502
+ <div className="ball" />
503
+ typing
504
+ </div>
505
+ </div>
506
+ )}
507
+ </form>
508
+ </div>
450
509
  );
451
510
  };
452
511
 
@@ -51,7 +51,22 @@ const Messages = () => {
51
51
  const handleNewMessage = (newMessage: any) => {
52
52
  newMessage.shouldShake = true;
53
53
  console.log("📩 New message received:", newMessage);
54
- setMessages((prevMessages) => [...prevMessages, newMessage[1]]);
54
+ // setMessages((prevMessages) => [...prevMessages, newMessage[1]]);
55
+ setMessages(prevMessages => {
56
+ console.log("prevMessages", prevMessages);
57
+
58
+ const isDuplicate = prevMessages.some(msg =>
59
+ msg._id === newMessage[1]._id ||
60
+ (msg.isOptimistic && msg.senderId === userId && msg.message === newMessage[1].message)
61
+ );
62
+ console.log('isDuplicate', isDuplicate);
63
+
64
+ if (isDuplicate) {
65
+ return prevMessages;
66
+ }
67
+
68
+ return [...prevMessages, newMessage[1]];
69
+ });
55
70
  };
56
71
 
57
72
  // const handleNewMessage = (newMessage: any) => {
@@ -72,7 +87,7 @@ const Messages = () => {
72
87
  status: string;
73
88
  }) => {
74
89
  setMessages((prev) =>
75
- prev.map((msg) => (msg.id === messageId ? { ...msg, status } : msg))
90
+ prev.map((msg) => (msg._id === messageId ? { ...msg, status } : msg))
76
91
  );
77
92
  };
78
93
 
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
1
2
  // import { useSocketContext } from "../../context/SocketContext";
2
3
  // import useConversation from "../../zustand/useConversation";
3
4
 
@@ -12,7 +12,29 @@ const Conversations = () => {
12
12
  // const { loading, conversations } = useGetConversations();
13
13
  return (
14
14
  <div className="chatSidebarConversations">
15
- <h2 className="text-lg font-semibold text-gray-700">All Chats</h2>
15
+ <h2 className="text-lg font-semibold text-gray-700">All Messages</h2>
16
+ {(!conversations || conversations.length === 0) && (
17
+ <div className="flex flex-col items-center justify-center p-8 text-center">
18
+ <div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 mb-4">
19
+ <svg
20
+ xmlns="http://www.w3.org/2000/svg"
21
+ width="32"
22
+ height="32"
23
+ viewBox="0 0 24 24"
24
+ fill="none"
25
+ stroke="currentColor"
26
+ strokeWidth="2"
27
+ strokeLinecap="round"
28
+ strokeLinejoin="round"
29
+ className="text-gray-500"
30
+ >
31
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
32
+ </svg>
33
+ </div>
34
+ <h3 className="text-xl font-semibold mb-1">No Conversations</h3>
35
+ <p className="text-gray-500 text-sm mb-4">There are no conversations under &quot;All messages&quot;</p>
36
+ </div>
37
+ )}
16
38
  {conversations?.map((conversation: any, idx: any) => (
17
39
  <Conversation
18
40
  key={conversation._id}
@@ -21,9 +43,11 @@ const Conversations = () => {
21
43
  />
22
44
  ))}
23
45
 
46
+
47
+
24
48
  {/* {loading ? <span className='loading loading-spinner mx-auto'></span> : null} */}
25
49
  </div>
26
50
  );
27
51
  };
28
52
 
29
- export default Conversations;
53
+ export default Conversations;
@@ -22,12 +22,20 @@ const {userId} =useChatContext()
22
22
  return
23
23
  }
24
24
 
25
- const conversation = data?.find((c: { _id: string; participantDetails: { username: string } }) =>
25
+ const conversation = data?.find((c: { _id: string; participantDetails: { username: string; firstname?: string; idpic?: string } }) =>
26
26
  c.participantDetails.username.toLowerCase().includes(search.toLowerCase())
27
27
  );
28
28
 
29
29
  if (conversation) {
30
- setSelectedConversation(conversation);
30
+ const updatedConversation = {
31
+ ...conversation,
32
+ participantDetails: {
33
+ ...conversation.participantDetails,
34
+ firstname: conversation.participantDetails.username || "Unknown",
35
+ idpic: conversation.participantDetails.profilePic || "default-idpic.png",
36
+ },
37
+ };
38
+ setSelectedConversation(updatedConversation);
31
39
  setSearch("");
32
40
  }
33
41
  console.error("No such user found!");
@@ -50,6 +58,10 @@ const {userId} =useChatContext()
50
58
  </div>
51
59
  </form>
52
60
  </>
61
+
62
+
63
+
64
+
53
65
  )
54
66
  }
55
67
 
@@ -2,12 +2,11 @@ import Conversations from "./Conversations";
2
2
  import SearchInput from "./SearchInput";
3
3
 
4
4
  export const Sidebar = () => {
5
- return (
6
- <div className='chatSidebarMain'>
5
+ return (
6
+ <div className=''>
7
7
  <SearchInput />
8
8
  <div className='divider px-3'></div>
9
9
  <Conversations />
10
-
11
- </div>
12
- )
10
+ </div>
11
+ )
13
12
  }
@@ -1,11 +1,15 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { FileType } from "../components/common/FilePreview";
2
3
  import { getApiClient } from "../lib/api/apiClient";
3
4
  import { Path } from "../lib/api/endpoint";
4
5
 
5
- export const sendMessage = async ({ chatId,senderId, message }: { chatId: any; senderId:any; message: string }) => {
6
+ export const sendMessage = async ({ chatId,senderId, message,attachments }: { chatId: any; senderId:any; message: string,attachments: { type: FileType; url: string; name: string; size: number; }[] }) => {
6
7
  const apiClient = getApiClient();
8
+ console.log("sendMessage", chatId, senderId, message, attachments); // Log the parameters
9
+
7
10
  const response = await apiClient.post(`${Path.sendmessage}/${chatId}/${senderId}`, {
8
- message:message
11
+ message:message,
12
+ attachments:attachments
9
13
  })
10
14
  return response.data;
11
15
  };
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { FileType } from "../components/common/FilePreview";
2
3
  import { create } from "zustand";
3
4
 
4
5
  interface ChatUIState {
@@ -16,7 +17,23 @@ interface ChatUIState {
16
17
  setSelectedConversation: (
17
18
  selectedConversation: ChatUIState["selectedConversation"]
18
19
  ) => void;
19
- messages: { id: string; text: string; sender: string; status: string }[];
20
+ messages: {
21
+ _id: string;
22
+ text: string;
23
+ senderId: string;
24
+ status: string ;
25
+ isOptimistic:boolean;
26
+ message:string;
27
+ createdAt: string;
28
+ media: {
29
+ type: FileType;
30
+ url: string;
31
+ name: string;
32
+ size: number;
33
+ uploadProgress: number;
34
+ uploadError:string
35
+ }[];
36
+ isUploading: boolean;}[];
20
37
  setMessages: (messages: ChatUIState["messages"] | ((prev: ChatUIState["messages"]) => ChatUIState["messages"])) => void;
21
38
  updateMessageStatus: (messageId: string, status: string) => void;
22
39
  toggleChat: () => void;
@@ -38,7 +55,7 @@ const useChatUIStore = create<ChatUIState>((set) => ({
38
55
  updateMessageStatus: (messageId, status) =>
39
56
  set((state) => ({
40
57
  messages: state.messages.map((msg) =>
41
- msg.id === messageId ? { ...msg, status } : msg
58
+ msg._id === messageId ? { ...msg, status } : msg
42
59
  ),
43
60
  })),
44
61
  setSelectedConversation: (selectedConversation) => set({ selectedConversation }),
package/src/types/type.ts CHANGED
@@ -69,4 +69,4 @@ export interface ConversationProps {
69
69
  lastIdx: boolean;
70
70
  }
71
71
 
72
- export type MessageStatus = 'sent' | 'delivered' | 'read';
72
+ export type MessageStatus = 'sent' | 'delivered' | 'read' | 'sending' | 'failed';