Updated audio playback and preloading logic to check for and handle returned promises from play() and load() methods. This prevents uncaught promise rejections in browsers where these methods may return undefined, improving reliability and error handling for notification sounds.
835 lines
30 KiB
TypeScript
835 lines
30 KiB
TypeScript
"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";
|
|
import { useIsTouchDevice } from "@/hooks/use-mobile";
|
|
import { useChromebookScroll, useSmoothScrollToBottom } from "@/hooks/use-chromebook-scroll";
|
|
import { useChromebookKeyboard, useChatFocus } from "@/hooks/use-chromebook-keyboard";
|
|
|
|
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 <ImageIcon className="h-5 w-5" />;
|
|
}
|
|
|
|
// Document files
|
|
if (/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|csv)($|\?)/i.test(fileName)) {
|
|
return <FileText className="h-5 w-5" />;
|
|
}
|
|
|
|
// Default file icon
|
|
return <File className="h-5 w-5" />;
|
|
};
|
|
|
|
export default function ChatDetail({ chatId }: { chatId: string }) {
|
|
const router = useRouter();
|
|
const [chat, setChat] = useState<Chat | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [message, setMessage] = useState("");
|
|
const [sending, setSending] = useState(false);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [chatData, setChatData] = useState<any>(null);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const [previousMessageCount, setPreviousMessageCount] = useState<number>(0);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
const markReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const isPollingRef = useRef<boolean>(false);
|
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
|
const [selectedMessageIndex, setSelectedMessageIndex] = useState<number | null>(null);
|
|
const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState<number | null>(null);
|
|
const seenMessageIdsRef = useRef<Set<string>>(new Set());
|
|
const isTouchDevice = useIsTouchDevice();
|
|
const scrollContainerRef = useChromebookScroll();
|
|
const { scrollToBottom, scrollToBottomInstant } = useSmoothScrollToBottom();
|
|
useChromebookKeyboard();
|
|
const { focusMessageInput, focusNextMessage, focusPreviousMessage } = useChatFocus();
|
|
|
|
// Scroll to bottom utility functions
|
|
const scrollToBottomHandler = () => {
|
|
if (scrollContainerRef.current) {
|
|
scrollToBottom(scrollContainerRef.current);
|
|
} else {
|
|
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('/hohoho.mp3');
|
|
|
|
// Fallback if hohoho.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;
|
|
const playPromise = audioRef.current.play();
|
|
if (playPromise !== undefined) {
|
|
playPromise.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(() => {
|
|
scrollToBottomHandler();
|
|
}, 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<HTMLInputElement>) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (message.trim()) {
|
|
sendMessage(message);
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
// Clear the input on Escape
|
|
setMessage('');
|
|
focusMessageInput();
|
|
} else if (e.key === 'ArrowUp' && message === '') {
|
|
// Load previous message on Arrow Up when input is empty
|
|
e.preventDefault();
|
|
const lastVendorMessage = [...messages].reverse().find(msg => msg.sender === 'vendor');
|
|
if (lastVendorMessage) {
|
|
setMessage(lastVendorMessage.content);
|
|
} else {
|
|
focusPreviousMessage();
|
|
}
|
|
} else if (e.key === 'ArrowDown' && message === '') {
|
|
// Focus next message
|
|
e.preventDefault();
|
|
focusNextMessage();
|
|
} else if (e.key === 'Tab') {
|
|
// Enhanced tab navigation for Chromebooks
|
|
e.preventDefault();
|
|
const focusableElements = document.querySelectorAll(
|
|
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
) as NodeListOf<HTMLElement>;
|
|
|
|
const currentIndex = Array.from(focusableElements).indexOf(document.activeElement as HTMLElement);
|
|
const nextIndex = e.shiftKey
|
|
? (currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1)
|
|
: (currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0);
|
|
|
|
focusableElements[nextIndex]?.focus();
|
|
}
|
|
};
|
|
|
|
// 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 (
|
|
<div className="flex flex-col h-screen w-full relative">
|
|
<div className="border-b py-2 px-4 flex items-center space-x-2 bg-card z-10">
|
|
<Button variant="ghost" size="icon" onClick={handleBackClick}>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
<span className="text-lg font-semibold">Loading conversation...</span>
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!chat) {
|
|
return (
|
|
<div className="flex flex-col h-screen w-full relative">
|
|
<div className="border-b py-2 px-4 flex items-center space-x-2 bg-card z-10">
|
|
<Button variant="ghost" size="icon" onClick={handleBackClick}>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
<span className="text-lg font-semibold">Chat not found</span>
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<p className="text-muted-foreground mb-4">This conversation doesn't exist or you don't have access to it.</p>
|
|
<Button onClick={handleBackClick}>Back to Chats</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-screen w-full relative">
|
|
<div className={cn(
|
|
"border-b bg-card z-10 flex items-center justify-between",
|
|
isTouchDevice ? "h-20 px-3" : "h-16 px-4"
|
|
)}>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleBackClick}
|
|
className={cn(
|
|
"transition-all duration-200",
|
|
isTouchDevice ? "h-12 w-12" : "h-10 w-10"
|
|
)}
|
|
aria-label="Go back to chats"
|
|
>
|
|
<ArrowLeft className={cn(
|
|
isTouchDevice ? "h-6 w-6" : "h-5 w-5"
|
|
)} />
|
|
</Button>
|
|
<div className="flex flex-col">
|
|
<h3 className={cn(
|
|
"font-semibold",
|
|
isTouchDevice ? "text-xl" : "text-lg"
|
|
)}>
|
|
Chat with Customer {chat.buyerId}
|
|
</h3>
|
|
{chat.telegramUsername && (
|
|
<span className={cn(
|
|
"text-muted-foreground",
|
|
isTouchDevice ? "text-base" : "text-sm"
|
|
)}>
|
|
@{chat.telegramUsername}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<BuyerOrderInfo buyerId={chat.buyerId} chatId={chatId} />
|
|
</div>
|
|
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className={cn(
|
|
isTouchDevice
|
|
? "flex-1 overflow-y-auto space-y-2 p-3 pb-[calc(112px+env(safe-area-inset-bottom))]"
|
|
: "flex-1 overflow-y-auto space-y-2 p-2 pb-[calc(88px+env(safe-area-inset-bottom))]"
|
|
)}
|
|
role="log"
|
|
aria-label="Chat messages"
|
|
aria-live="polite"
|
|
aria-atomic="false"
|
|
style={{
|
|
WebkitOverflowScrolling: 'touch',
|
|
overscrollBehavior: 'contain'
|
|
}}
|
|
>
|
|
{chat.messages.length === 0 ? (
|
|
<div className="h-full flex items-center justify-center">
|
|
<p className="text-muted-foreground">No messages yet. Send one to start the conversation.</p>
|
|
</div>
|
|
) : (
|
|
chat.messages.map((msg, messageIndex) => (
|
|
<div
|
|
key={msg._id || messageIndex}
|
|
className={cn(
|
|
"flex mb-4",
|
|
msg.sender === "vendor" ? "justify-end" : "justify-start"
|
|
)}
|
|
role="article"
|
|
aria-label={`Message from ${msg.sender === "vendor" ? "you" : "customer"}`}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"max-w-[90%] rounded-lg chat-message",
|
|
isTouchDevice ? "p-4" : "p-3",
|
|
msg.sender === "vendor"
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted"
|
|
)}
|
|
>
|
|
<div className="flex items-center space-x-2 mb-1">
|
|
{msg.sender === "buyer" && (
|
|
<Avatar className="h-6 w-6" aria-label="Customer avatar">
|
|
<AvatarFallback className="text-xs">
|
|
{chat.buyerId.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
)}
|
|
<span className="text-xs opacity-70" aria-label={`Sent ${formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })}`}>
|
|
{formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
<p className="whitespace-pre-wrap break-words" role="text">{msg.content}</p>
|
|
{/* Show attachments if any */}
|
|
{msg.attachments && msg.attachments.length > 0 && (
|
|
<div className="mt-2 space-y-2">
|
|
{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 ? (
|
|
<div
|
|
key={`attachment-${attachmentIndex}`}
|
|
className="rounded-md overflow-hidden bg-background/20 p-1"
|
|
onClick={() => handleImageClick(attachment, messageIndex, attachmentIndex)}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`View image: ${fileName}`}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
handleImageClick(attachment, messageIndex, attachmentIndex);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-xs opacity-70 flex items-center">
|
|
<ImageIcon className="h-3 w-3 mr-1" aria-hidden="true" />
|
|
{fileName}
|
|
</span>
|
|
<a
|
|
href={attachment}
|
|
download={fileName}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs opacity-70 hover:opacity-100"
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label={`Download image: ${fileName}`}
|
|
>
|
|
<Download className="h-3 w-3" aria-hidden="true" />
|
|
</a>
|
|
</div>
|
|
<img
|
|
src={attachment}
|
|
alt={fileName}
|
|
className="max-w-full max-h-60 object-contain cursor-pointer hover:opacity-90 transition-opacity rounded"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = "/placeholder-image.svg";
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
// Render file attachment
|
|
<div key={`attachment-${attachmentIndex}`} className="flex items-center bg-background/20 rounded-md p-2 hover:bg-background/30 transition-colors">
|
|
<div className="mr-2" aria-hidden="true">
|
|
{getFileIcon(attachment)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm truncate font-medium" aria-label={`File: ${fileName}`}>
|
|
{fileName}
|
|
</div>
|
|
</div>
|
|
<a
|
|
href={attachment}
|
|
download={fileName}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="ml-2 p-1 rounded-sm hover:bg-background/50"
|
|
aria-label={`Download file: ${fileName}`}
|
|
>
|
|
<Download className="h-4 w-4" aria-hidden="true" />
|
|
</a>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
<div className={cn(
|
|
"absolute bottom-0 left-0 right-0 border-t border-border bg-background",
|
|
isTouchDevice ? "p-3" : "p-4",
|
|
"pb-[env(safe-area-inset-bottom)]"
|
|
)}>
|
|
<form onSubmit={handleSendMessage} className="flex space-x-2">
|
|
<Input
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
placeholder="Type your message..."
|
|
disabled={sending}
|
|
className={cn(
|
|
"flex-1 text-base transition-all duration-200 form-input",
|
|
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px]"
|
|
)}
|
|
onKeyDown={handleKeyDown}
|
|
autoFocus
|
|
aria-label="Message input"
|
|
aria-describedby="message-help"
|
|
role="textbox"
|
|
autoComplete="off"
|
|
spellCheck="true"
|
|
maxLength={2000}
|
|
style={{
|
|
WebkitAppearance: 'none',
|
|
borderRadius: '0.5rem'
|
|
}}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
disabled={sending || !message.trim()}
|
|
aria-label={sending ? "Sending message" : "Send message"}
|
|
className={cn(
|
|
"transition-all duration-200 btn-chromebook",
|
|
isTouchDevice ? "min-h-[52px] min-w-[52px]" : "min-h-[48px] min-w-[48px]"
|
|
)}
|
|
style={{
|
|
WebkitAppearance: 'none',
|
|
touchAction: 'manipulation'
|
|
}}
|
|
>
|
|
{sending ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
</Button>
|
|
</form>
|
|
<div id="message-help" className="sr-only">
|
|
Press Enter to send message, Shift+Enter for new line, Escape to clear, Arrow Up to load previous message. Maximum 2000 characters.
|
|
</div>
|
|
</div>
|
|
|
|
{/* Update the image viewer modal */}
|
|
<ImageViewerModal
|
|
isOpen={!!selectedImage}
|
|
onClose={() => {
|
|
setSelectedImage(null);
|
|
setSelectedMessageIndex(null);
|
|
setSelectedAttachmentIndex(null);
|
|
}}
|
|
imageUrl={selectedImage || ""}
|
|
onNavigate={handleImageNavigation}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|