/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useCallback, useEffect, useRef, useState } from "react"; import { useMessageMutation } from "../../hooks/mutations/useSendMessage"; import { useChatContext } from "../../providers/ChatProvider"; import useChatUIStore from "../../stores/Zustant"; import { FilePreview, FileType } from "../common/FilePreview"; import { MessageStatus } from "../../types/type"; import { Path } from "../../lib/api/endpoint"; import { getChatConfig } from "../../Chat.config"; const MAX_FILE_COUNT = 5; const ACCEPTED_IMAGE_TYPES = [ "image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm", "video/ogg", ]; const ACCEPTED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/ogg"]; const ACCEPTED_DOCUMENT_TYPES = [ "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "text/plain", ]; interface Attachment { file: File; type: FileType; previewUrl: string; uploadProgress?: number; uploadError?: string; } const MessageInput = () => { // const apiClient = getApiClient(); const { role, apiClient ,apiUrl} = getChatConfig(); const { socket, sendMessage, userId } = useChatContext(); const { selectedConversation, setMessages, setLastSentMessage } = useChatUIStore(); const [message, setMessage] = useState(""); const [message1, setMessage1] = useState(""); const mutation = useMessageMutation(); const [typingUser, setTypingUser] = useState(null); const [isSending, setIsSending] = useState(false); const [attachments, setAttachments] = useState([]); const [showAttachmentOptions, setShowAttachmentOptions] = useState(false); const fileInputRef = useRef(null); const attachmentsRef = useRef([]); const [tempMessageId, setTempMessageId] = useState(null); const [inputError, setInputError] = useState(null); const attachmentsContainerRef = useRef(null); const typingTimeoutRef = useRef | null>(null); const isTypingRef = useRef(false); const generateTempId = () => `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // Join chat room when conversation is selected const participantDetails = Array.isArray( selectedConversation?.participantDetails ) ? selectedConversation?.participantDetails : [selectedConversation?.participantDetails].filter(Boolean); const otherParticipant = participantDetails.find( (p: any) => p._id !== userId ); // --- Emit typing helpers (debounced / stateful) --- const emitTyping = useCallback(() => { if (!socket || socket.readyState !== WebSocket.OPEN) return; if (!selectedConversation?._id || !otherParticipant?._id) return; sendMessage({ event: "typing", data: { chatId: selectedConversation._id, userId, receiverId: otherParticipant._id, receiverRole: role === "customer" ? "provider" : "customer", }, }); }, [socket, selectedConversation?._id, otherParticipant?._id, sendMessage, userId, role]); const emitStopTyping = useCallback(() => { if (!socket || socket.readyState !== WebSocket.OPEN) return; if (!selectedConversation?._id || !otherParticipant?._id) return; sendMessage({ event: "stopTyping", data: { chatId: selectedConversation._id, userId, receiverId: otherParticipant._id, receiverRole: role === "customer" ? "provider" : "customer", }, }); }, [socket, selectedConversation?._id, otherParticipant?._id, sendMessage, userId, role]); const stopTypingNow = useCallback(() => { if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = null; } if (isTypingRef.current) { isTypingRef.current = false; emitStopTyping(); } }, [emitStopTyping]); useEffect(() => { if (selectedConversation?._id && socket?.readyState === WebSocket.OPEN) { sendMessage({ event: "joinChat", data: { chatId: selectedConversation._id, }, }); } }, [selectedConversation?._id, socket, sendMessage]); useEffect(() => { // Clear all input state when conversation changes setMessage(""); setMessage1(""); setAttachments([]); setInputError(null); setShowAttachmentOptions(false); stopTypingNow(); // Clean up any existing file input if (fileInputRef.current) { fileInputRef.current.value = ""; } }, [selectedConversation?._id, stopTypingNow]); // Typing indicator logic // useEffect(() => { // if (!socket || !selectedConversation?._id) return; // if (message.trim() !== "") { // setIsTyping(true); // sendMessage({ // event: "typing", // data: { // chatId: selectedConversation._id, // userId, // receiverId: otherParticipant?._id, // receiverRole: role === "customer" ? "provider" : "customer", // }, // }); // } // typingTimeoutRef.current = setTimeout(() => { // if (message.trim() === "") { // setIsTyping(false); // sendMessage({ // event: "stopTyping", // data: { // chatId: selectedConversation._id, // userId, // receiverId: otherParticipant?._id, // receiverRole: role === "customer" ? "provider" : "customer", // }, // }); // } // }, 200); // return () => { // if (typingTimeoutRef.current) { // clearTimeout(typingTimeoutRef.current); // } // }; // }, [message, socket, selectedConversation?._id, userId, sendMessage]); // // Listen for typing indicators from others // useEffect(() => { // if (!socket || !selectedConversation?._id) return; // const handleMessage = (event: MessageEvent) => { // try { // const data = JSON.parse(event.data); // if ( // data.event === "typing" && // data.data.chatId === selectedConversation._id // ) { // setTypingUser(data.data.userId); // } else if ( // data.event === "stopTyping" && // data.data.chatId === selectedConversation._id // ) { // setTypingUser((prev) => (prev === data.data.userId ? null : prev)); // } // } catch (error) { // console.error("Error parsing typing message:", error); // } // }; // socket.addEventListener("message", handleMessage); // return () => { // socket.removeEventListener("message", handleMessage); // }; // }, [socket, selectedConversation?._id]); // ✅ FIXED Typing indicator logic: // - Send "typing" once when user starts typing // - Send "stopTyping" after inactivity (even if text remains) // - If input becomes empty -> stopTyping once useEffect(() => { if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); const hasText = message.trim().length > 0; if (!hasText) { // If input empty, ensure stopTyping is emitted once if (isTypingRef.current) { isTypingRef.current = false; emitStopTyping(); } return; } // Has text if (!isTypingRef.current) { isTypingRef.current = true; emitTyping(); } // If user pauses, stop typing after 1s typingTimeoutRef.current = setTimeout(() => { if (isTypingRef.current) { isTypingRef.current = false; emitStopTyping(); } }, 1000); return () => { if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); }; }, [message, emitTyping, emitStopTyping]); // Listen for typing indicators from others useEffect(() => { if (!socket || !selectedConversation?._id) return; const handleMessage = (event: MessageEvent) => { try { const data = JSON.parse(event.data); if (data.event === "typing" && data.data.chatId === selectedConversation._id) { setTypingUser(data.data.userId); } else if (data.event === "stopTyping" && data.data.chatId === selectedConversation._id) { setTypingUser((prev) => (prev === data.data.userId ? null : prev)); } } catch (error) { console.error("Error parsing typing message:", error); } }; socket.addEventListener("message", handleMessage); return () => { socket.removeEventListener("message", handleMessage); }; }, [socket, selectedConversation?._id]); const validateInput = (text: string) => { const emailRegex = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i; if (emailRegex.test(text)) { return "To protect your account, make sure not to share any personal or sensitive information."; } const phoneRegex = /(\+?\d{1,4}[\s-]?)?(\(?\d{3}\)?[\s-]?)?\d{3}[\s-]?\d{4}/; if (phoneRegex.test(text)) { return "To protect your account, make sure not to share any personal or sensitive information."; } return null; }; const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; const validationError = validateInput(newValue); if (validationError) { setInputError(validationError); setMessage(newValue); setMessage1(newValue); } else { setInputError(null); setMessage(newValue); setMessage1(newValue); } }; const getFileType = (mimeType: string): FileType | null => { if (ACCEPTED_VIDEO_TYPES.includes(mimeType)) return "video"; if (ACCEPTED_IMAGE_TYPES.includes(mimeType)) return "image"; if (ACCEPTED_DOCUMENT_TYPES.includes(mimeType)) return "document"; return null; }; const uploadToS3 = async ( file: File, onProgress?: (progress: number) => void ): Promise<{ url: string; name: string; size: number; type: FileType }> => { const response = await apiClient.post(`${apiUrl}/${Path.preSignUrl}`, { fileName: file.name, fileType: file.type, conversationId: selectedConversation?._id, }); const { signedUrl, fileUrl } = await response.data; const xhr = new XMLHttpRequest(); xhr.open("PUT", signedUrl, true); xhr.setRequestHeader("Content-Type", file.type); return new Promise((resolve, reject) => { xhr.upload.onprogress = (event) => { if (event.lengthComputable && onProgress) { const progress = Math.round((event.loaded / event.total) * 100); onProgress(progress); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve({ url: fileUrl, name: file.name, size: file.size, type: getFileType(file.type), }); } else { reject(new Error("Upload failed")); } }; xhr.onerror = () => reject(new Error("Upload failed")); xhr.send(file); }); }; // const otherParticipant = selectedConversation?.participantDetails?.find( // (p:any) => p._id !== userId // ); const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if ((!message.trim() && attachmentsRef.current.length === 0) || isSending) return; setIsSending(true); setAttachments([]); setMessage(""); const tempId = generateTempId(); setTempMessageId(tempId); const optimisticMessage = { _id: tempId, text: message1, message: message1, sender: { senderId: userId, role: role, }, status: "pending" as MessageStatus, createdAt: new Date().toISOString(), media: attachmentsRef.current.map((att) => ({ type: att.type, url: att.previewUrl, name: att.file.name, size: att.file.size, uploadProgress: 0, uploadError: "", })), isUploading: true, isOptimistic: true, }; setMessages((prev) => [...prev, optimisticMessage]); try { const uploadedFiles = await Promise.all( attachmentsRef.current.map(async (attachment, index) => { try { const result = await uploadToS3(attachment.file, (progress) => { setMessages((prev) => prev.map((msg) => { if (msg._id === tempId) { const updatedMedia = [...msg.media!]; updatedMedia[index] = { ...updatedMedia[index], uploadProgress: progress, }; return { ...msg, media: updatedMedia, }; } return msg; }) ); }); return result; } catch (error) { console.error( `Error uploading file ${attachment.file.name}:`, error ); setMessages((prev) => prev.map((msg) => { if (msg._id === tempId) { const updatedMedia = [...msg.media!]; updatedMedia[index] = { ...updatedMedia[index], uploadError: "Upload failed", }; return { ...msg, media: updatedMedia, }; } return msg; }) ); return null; } }) ); const successfulUploads = uploadedFiles.filter((file) => file !== null); if (!otherParticipant?._id) { console.error("Cannot send message: receiver ID is missing."); setIsSending(false); return; } mutation.mutate( { receiverId: otherParticipant._id, senderId: userId, message: message1, attachments: successfulUploads, bookingId: selectedConversation?.type === "service" ? selectedConversation?.bookingId : undefined, serviceTitle: selectedConversation?.type === "service" ? selectedConversation?.title : undefined, type: selectedConversation?.type, serviceId: selectedConversation?.type === "service" ? selectedConversation?.serviceId : undefined, senderRole: role, receiverRole: role === "customer" ? "provider" : "customer", }, { onSuccess: (data) => { const confirmedMessage = data[1]; setMessages((prev) => { const filtered = prev.filter( (msg) => msg._id !== tempMessageId ); return [ ...filtered, { ...confirmedMessage, isUploading: false, isOptimistic: false, }, ]; }); // Update the sidebar last message for the sender immediately setLastSentMessage({ _id: confirmedMessage._id, conversationId: selectedConversation?._id ?? "", message: message1, senderId: userId, media: successfulUploads, status: confirmedMessage.status ?? "sent", createdAt: confirmedMessage.createdAt ?? new Date().toISOString(), updatedAt: confirmedMessage.updatedAt ?? new Date().toISOString(), }); // Send message via WebSocket sendMessage({ event: "sendMessage", data: { chatId: selectedConversation?._id, message: message1, messageId: confirmedMessage._id, attachments: successfulUploads, senderId: userId, receiverId: otherParticipant._id, receiverRole: role === "customer" ? "provider" : "customer", senderRole: role, }, }); }, onError: (error) => { console.error("Error in sending message:", error); setMessages((prev) => prev.map((msg) => msg._id === tempId ? { ...msg, status: "failed" } : msg ) ); }, } ); } catch (error) { console.error("Error sending message:", error); setMessages((prev) => prev.map((msg) => msg._id === tempId ? { ...msg, status: "failed" } : msg ) ); } finally { setIsSending(false); setMessage1(""); setAttachments([]); setTempMessageId(null); } }, [ message, message1, selectedConversation, userId, isSending, mutation, setMessages, sendMessage, ] ); // Clean up object URLs when component unmounts useEffect(() => { return () => { attachments.forEach((attachment) => { URL.revokeObjectURL(attachment.previewUrl); }); }; }, [attachments]); // Update attachments ref useEffect(() => { attachmentsRef.current = attachments; }, [attachments]); {/* File attachment handlers */} // File attachment handlers const handleAttachmentClick = () => { setShowAttachmentOptions(!showAttachmentOptions); }; const handleFileSelect = (type: FileType) => { if (fileInputRef.current) { fileInputRef.current.accept = getAcceptString(type); fileInputRef.current.click(); } setShowAttachmentOptions(false); }; const getAcceptString = (type: FileType): string => { switch (type) { case "image": return ACCEPTED_IMAGE_TYPES.join(","); case "video": return ACCEPTED_VIDEO_TYPES.join(","); case "document": return ACCEPTED_DOCUMENT_TYPES.join(","); default: return "*"; } }; const FILE_SIZE_LIMITS_MB = { image: 10, video: 120, document: 10, } as const; const getMaxSizeForType = (type: Attachment["type"] | null) => { if (!type) return 0; return FILE_SIZE_LIMITS_MB[type] * 1024 * 1024; }; // const handleFileChange = async (e: React.ChangeEvent) => { // const files = e.target.files; // if (!files || files.length === 0) return; // if (attachments.length + files.length > MAX_FILE_COUNT) { // alert(`You can only attach up to ${MAX_FILE_COUNT} files`); // return; // } // const newFilesSize = Array.from(files).reduce( // (total, file) => total + file.size, // 0 // ); // const currentAttachmentsSize = attachments.reduce( // (total, att) => total + att.file.size, // 0 // ); // if ( // currentAttachmentsSize + newFilesSize > // MAX_FILE_SIZE_MB * 1024 * 1024 // ) { // alert(`Total file size cannot exceed ${MAX_FILE_SIZE_MB}MB`); // return; // } // const newAttachments: Attachment[] = []; // for (let i = 0; i < files.length; i++) { // const file = files[i]; // const fileType = getFileType(file.type); // if (!fileType) { // console.error(`Unsupported file type: ${file.type}`); // continue; // } // if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { // console.error(`File too large: ${file.name}`); // continue; // } // const previewUrl = // fileType === "document" // ? URL.createObjectURL(new Blob([""], { type: "application/pdf" })) // : URL.createObjectURL(file); // newAttachments.push({ // file, // type: fileType, // previewUrl, // }); // } // setAttachments((prev) => [...prev, ...newAttachments]); // if (fileInputRef.current) { // fileInputRef.current.value = ""; // } // }; const handleFileChange = async (e: React.ChangeEvent) => { setInputError(null); const files = e.target.files; if (!files || files.length === 0) return; const newAttachments: Attachment[] = []; if (attachments.length + files.length > MAX_FILE_COUNT) { return setInputError(`You can only attach up to ${MAX_FILE_COUNT} files`); // toast / inline message } for (let i = 0; i < files.length; i++) { const file = files[i]; const fileType = getFileType(file.type); if (!fileType) { setInputError(`Unsupported file: ${file.name}`); continue; } const maxSize = getMaxSizeForType(fileType); if (file.size > maxSize) { setInputError( `${file.name} exceeds ${FILE_SIZE_LIMITS_MB[fileType]}MB limit` ); continue; } const previewUrl = fileType === "document" ? URL.createObjectURL(new Blob([""], { type: "application/pdf" })) : URL.createObjectURL(file); newAttachments.push({ file, type: fileType, previewUrl, }); } if (newAttachments.length) { setAttachments((prev) => [...prev, ...newAttachments]); setInputError(null); } if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const scrollAttachments = (direction: "left" | "right") => { if (attachmentsContainerRef.current) { const scrollAmount = direction === "right" ? 200 : -200; attachmentsContainerRef.current.scrollBy({ left: scrollAmount, behavior: "smooth", }); } }; const removeAttachment = (index: number) => { setInputError(null); setAttachments((prev) => { const newAttachments = [...prev]; URL.revokeObjectURL(newAttachments[index].previewUrl); newAttachments.splice(index, 1); return newAttachments; }); }; {/* end of file attachment handlers */} return (
{attachments.length > 0 && (
{attachments.map((attachment, index) => ( removeAttachment(index)} /> ))} {attachments.length < MAX_FILE_COUNT && (
fileInputRef.current?.click()} >
+
Add more
)}
)}
{inputError && (

{inputError}

)}
{showAttachmentOptions && (
)}