"use client" import React, { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { cn } from "@/lib/utils/general"; import { formatDistanceToNow } from "date-fns"; import axios from "axios"; import { toast } from "sonner"; import { ArrowLeft, Send, RefreshCw, File, FileText, Image as ImageIcon, Download } from "lucide-react"; import { getCookie, clientFetch } from "@/lib/api"; import { ImageViewerModal } from "@/components/modals/image-viewer-modal"; import BuyerOrderInfo from "./BuyerOrderInfo"; interface Message { _id: string; sender: "buyer" | "vendor"; content: string; attachments: string[]; read: boolean; createdAt: string; buyerId: string; vendorId: string; } interface Chat { _id: string; buyerId: string; vendorId: string; storeId: string; messages: Message[]; lastUpdated: string; orderId?: string; telegramUsername?: string | null; } // Helper function to extract filename from URL const getFileNameFromUrl = (url: string): string => { // Try to extract filename from the URL path const pathParts = url.split('/'); const lastPart = pathParts[pathParts.length - 1]; // Remove query parameters if any const fileNameParts = lastPart.split('?'); let fileName = fileNameParts[0]; // If filename is too long or not found, create a generic name if (!fileName || fileName.length > 30) { return 'attachment'; } // URL decode the filename (handle spaces and special characters) try { fileName = decodeURIComponent(fileName); } catch (e) { // If decoding fails, use the original } return fileName; }; // Helper function to get file icon based on extension or URL pattern const getFileIcon = (url: string): React.ReactNode => { const fileName = url.toLowerCase(); // Image files if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(fileName) || url.includes('/photos/') || url.includes('/photo/')) { return ; } // Document files if (/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|csv)($|\?)/i.test(fileName)) { return ; } // Default file icon return ; }; export default function ChatDetail({ chatId }: { chatId: string }) { const router = useRouter(); const [chat, setChat] = useState(null); const [loading, setLoading] = useState(true); const [message, setMessage] = useState(""); const [sending, setSending] = useState(false); const [messages, setMessages] = useState([]); const [chatData, setChatData] = useState(null); const messagesEndRef = useRef(null); const [previousMessageCount, setPreviousMessageCount] = useState(0); const audioRef = useRef(null); const markReadTimeoutRef = useRef(null); const isPollingRef = useRef(false); const [selectedImage, setSelectedImage] = useState(null); const [selectedMessageIndex, setSelectedMessageIndex] = useState(null); const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(null); const seenMessageIdsRef = useRef>(new Set()); // Scroll to bottom utility functions const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; const isNearBottom = () => { if (!messagesEndRef.current) return true; const container = messagesEndRef.current.parentElement; if (!container) return true; const { scrollTop, scrollHeight, clientHeight } = container; // Consider "near bottom" if within 100px of the bottom return scrollHeight - (scrollTop + clientHeight) < 100; }; // Initialize audio element useEffect(() => { // Create audio element for notification sound audioRef.current = new Audio('/notification.mp3'); // Fallback if notification.mp3 doesn't exist - use browser API for a simple beep audioRef.current.addEventListener('error', () => { audioRef.current = null; }); return () => { if (audioRef.current) { audioRef.current = null; } // Clear any pending timeouts when component unmounts if (markReadTimeoutRef.current) { clearTimeout(markReadTimeoutRef.current); } }; }, []); // Function to play notification sound const playNotificationSound = () => { if (audioRef.current) { audioRef.current.currentTime = 0; audioRef.current.play().catch(err => { console.log('Error playing sound:', err); // Fallback to simple beep if audio file fails try { const context = new (window.AudioContext || (window as any).webkitAudioContext)(); const oscillator = context.createOscillator(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(800, context.currentTime); oscillator.connect(context.destination); oscillator.start(); oscillator.stop(context.currentTime + 0.2); } catch (e) { console.error('Could not play fallback audio', e); } }); } else { // Fallback to simple beep if audio element is not available try { const context = new (window.AudioContext || (window as any).webkitAudioContext)(); const oscillator = context.createOscillator(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(800, context.currentTime); oscillator.connect(context.destination); oscillator.start(); oscillator.stop(context.currentTime + 0.2); } catch (e) { console.error('Could not play fallback audio', e); } } }; // Function to mark messages as read const markMessagesAsRead = async () => { try { // Use clientFetch instead of direct axios await clientFetch(`/chats/${chatId}/mark-read`, { method: 'POST' }); console.log("Marked messages as read"); } catch (error) { console.error("Error marking messages as read:", error); } }; // Loading effect useEffect(() => { if (chatId) { fetchChatData(); } }, [chatId]); // Get a fresh auth axios instance const getAuthAxios = () => { const authToken = getCookie("Authorization"); if (!authToken) return null; return axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, headers: { Authorization: `Bearer ${authToken}`, "Content-Type": "application/json", } }); }; // Fetch chat information and messages const fetchChatData = async () => { if (!chatId) return; try { setLoading(true); // Use clientFetch to load chat data // For now, we're only loading chat data, but this could be extended // to load additional data in parallel (user profiles, order details, etc.) const response = await clientFetch(`/chats/${chatId}`); setChatData(response); setChat(response); // Set chat data to maintain compatibility // Set messages with a transition effect // If we already have messages, append new ones to avoid jumpiness if (messages.length > 0) { const existingMessageIds = new Set(messages.map(m => m._id)); const newMessages = response.messages.filter( (msg: Message) => !existingMessageIds.has(msg._id) ); if (newMessages.length > 0) { setMessages(prev => [...prev, ...newMessages]); // Mark all these messages as seen to avoid notification sounds newMessages.forEach((msg: Message) => { seenMessageIdsRef.current.add(msg._id); }); } else { // If we need to replace all messages (e.g., first load or refresh) setMessages(Array.isArray(response.messages) ? response.messages : []); // Mark all messages as seen if (Array.isArray(response.messages)) { response.messages.forEach((msg: Message) => { seenMessageIdsRef.current.add(msg._id); }); } } } else { // Initial load const initialMessages = Array.isArray(response.messages) ? response.messages : []; setMessages(initialMessages); // Mark all initial messages as seen initialMessages.forEach((msg: Message) => { seenMessageIdsRef.current.add(msg._id); }); } // Scroll to bottom on initial load setTimeout(() => { scrollToBottom(); }, 100); } catch (error) { console.error("Error fetching chat data:", error); toast.error("Failed to load chat"); } finally { setLoading(false); } }; // Setup polling for new messages useEffect(() => { // Set up a polling interval to check for new messages const pollInterval = setInterval(() => { if (chatId && !isPollingRef.current) { pollNewMessages(); } }, 3000); // Poll every 3 seconds return () => { clearInterval(pollInterval); }; }, [chatId]); // Poll for new messages without replacing existing ones const pollNewMessages = async () => { if (!chatId || isPollingRef.current) return; isPollingRef.current = true; try { const response = await clientFetch(`/chats/${chatId}`); // Update chat metadata setChatData(response); setChat(response); // Check if there are new messages if (Array.isArray(response.messages) && response.messages.length > 0) { // Get existing message IDs to avoid duplicates const existingIds = new Set(messages.map(m => m._id)); const newMessages = response.messages.filter((msg: Message) => !existingIds.has(msg._id)); if (newMessages.length > 0) { // Add only new messages to avoid re-rendering all messages setMessages(prev => [...prev, ...newMessages]); // Play notification sound only for new buyer messages we haven't seen before const unseenBuyerMessages = newMessages.filter((msg: Message) => msg.sender === 'buyer' && !seenMessageIdsRef.current.has(msg._id) ); // If we have unseen buyer messages, play sound and mark them as seen if (unseenBuyerMessages.length > 0) { playNotificationSound(); // Add these messages to our seen set unseenBuyerMessages.forEach((msg: Message) => { seenMessageIdsRef.current.add(msg._id); }); } // If near bottom, scroll to new messages if (isNearBottom()) { setTimeout(scrollToBottom, 50); } // Set timeout to mark new messages as read if (markReadTimeoutRef.current) { clearTimeout(markReadTimeoutRef.current); } markReadTimeoutRef.current = setTimeout(() => { markMessagesAsRead(); }, 1000); } } } catch (error) { console.error("Error polling new messages:", error); } finally { isPollingRef.current = false; } }; // Handle form submit for sending messages const handleSendMessage = (e: React.FormEvent) => { e.preventDefault(); if (!message.trim()) return; sendMessage(message); }; // Handle keyboard shortcuts for sending message const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (message.trim()) { sendMessage(message); } } }; // Function to send a new message const sendMessage = async (newMessage: string, file?: File | null) => { // Don't send empty messages if (!newMessage.trim() && !file) return; if (!chatId || !chatData) return; // Create a temporary message with a unique temporary ID const tempId = `temp-${Date.now()}`; const tempMessage: Message = { _id: tempId, sender: 'vendor', content: newMessage, attachments: file ? [URL.createObjectURL(file)] : [], read: true, createdAt: new Date().toISOString(), buyerId: chatData.buyerId || '', vendorId: chatData.vendorId || '' }; // Add the temp message ID to seen messages seenMessageIdsRef.current.add(tempId); // Optimistically add the temp message to the UI setMessages(prev => [...prev, tempMessage]); // Scroll to bottom to show the new message setTimeout(scrollToBottom, 50); try { setSending(true); let response; if (file) { // Use FormData for file uploads const formData = new FormData(); formData.append('content', newMessage); formData.append('attachment', file); response = await clientFetch(`/chats/${chatId}/message`, { method: 'POST', body: formData, // Don't set Content-Type with FormData - browser will set it with boundary }); } else { // Use JSON for text-only messages response = await clientFetch(`/chats/${chatId}/message`, { method: 'POST', body: JSON.stringify({ content: newMessage }), headers: { 'Content-Type': 'application/json' } }); } // Replace the temporary message with the real one from the server setMessages(prev => prev.map(msg => msg._id === tempId ? response : msg )); // Add the real message ID to seen messages if (response && response._id) { seenMessageIdsRef.current.add(response._id); } // Update the textarea value to empty setMessage(''); // Clear the file if there was one if (file) { setSelectedImage(null); setSelectedMessageIndex(null); setSelectedAttachmentIndex(null); } // Update the chat's last message if (chatData) { setChatData({ ...chatData, lastUpdated: new Date().toISOString() }); } } catch (error) { console.error('Error sending message:', error); toast.error('Failed to send message'); // Remove the temporary message if sending failed setMessages(prev => prev.filter(msg => msg._id !== tempId)); } finally { setSending(false); } }; const handleBackClick = () => { router.push("/dashboard/chats"); }; // Add function to handle image navigation const handleImageNavigation = (direction: 'prev' | 'next') => { if (!chat || selectedMessageIndex === null || selectedAttachmentIndex === null) return; // Get all images from all messages const allImages: { messageIndex: number; attachmentIndex: number; url: string }[] = []; chat.messages.forEach((msg, msgIndex) => { msg.attachments.forEach((att, attIndex) => { if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(att) || att.includes('/photos/') || att.includes('/photo/')) { allImages.push({ messageIndex: msgIndex, attachmentIndex: attIndex, url: att }); } }); }); if (allImages.length === 0) return; // Find current image index const currentIndex = allImages.findIndex(img => img.messageIndex === selectedMessageIndex && img.attachmentIndex === selectedAttachmentIndex ); if (currentIndex === -1) return; // Calculate new index let newIndex; if (direction === 'next') { newIndex = (currentIndex + 1) % allImages.length; } else { newIndex = currentIndex === 0 ? allImages.length - 1 : currentIndex - 1; } // Update state with new image const newImage = allImages[newIndex]; setSelectedMessageIndex(newImage.messageIndex); setSelectedAttachmentIndex(newImage.attachmentIndex); setSelectedImage(newImage.url); }; // Update the image click handler const handleImageClick = (imageUrl: string, messageIndex: number, attachmentIndex: number) => { setSelectedImage(imageUrl); setSelectedMessageIndex(messageIndex); setSelectedAttachmentIndex(attachmentIndex); }; if (loading) { return ( Loading conversation... ); } if (!chat) { return ( Chat not found This conversation doesn't exist or you don't have access to it. Back to Chats ); } return ( Chat with Customer {chat.buyerId} {chat.telegramUsername && ( @{chat.telegramUsername} )} {chat.messages.length === 0 ? ( No messages yet. Send one to start the conversation. ) : ( chat.messages.map((msg, messageIndex) => ( {msg.sender === "buyer" && ( {chat.buyerId.slice(0, 2).toUpperCase()} )} {formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })} {msg.content} {/* Show attachments if any */} {msg.attachments && msg.attachments.length > 0 && ( {msg.attachments.map((attachment, attachmentIndex) => { const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(attachment) || attachment.includes('/photos/') || attachment.includes('/photo/'); const fileName = getFileNameFromUrl(attachment); return isImage ? ( handleImageClick(attachment, messageIndex, attachmentIndex)} > {fileName} e.stopPropagation()} > { (e.target as HTMLImageElement).src = "/placeholder-image.svg"; }} /> ) : ( // Render file attachment {getFileIcon(attachment)} {fileName} ); })} )} )) )} setMessage(e.target.value)} placeholder="Type your message..." disabled={sending} className="flex-1" onKeyDown={handleKeyDown} autoFocus /> {sending ? : } {/* Update the image viewer modal */} { setSelectedImage(null); setSelectedMessageIndex(null); setSelectedAttachmentIndex(null); }} imageUrl={selectedImage || ""} onNavigate={handleImageNavigation} /> ); }
This conversation doesn't exist or you don't have access to it.
No messages yet. Send one to start the conversation.
{msg.content}