Files
ember-market-frontend/components/dashboard/ChatDetail.tsx
g c9c3f766a6 Improve admin ban UX, add product cloning, and enhance auth handling
Refines the admin ban page with better dialog state management and feedback during ban/unban actions. Adds a product cloning feature to the products dashboard and updates the product table to support cloning. Improves error handling in ChatDetail for authentication errors, and enhances middleware to handle auth check timeouts and network errors more gracefully. Also updates BanUserCard to validate user ID and ensure correct request formatting.
2025-12-27 20:58:08 +00:00

849 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: any) {
console.error("Error fetching chat data:", error);
// Don't redirect on auth errors - let the middleware handle it
// Only show error toast for non-auth errors
if (error?.message?.includes('401') || error?.message?.includes('403')) {
// Auth errors will be handled by middleware, don't show toast
console.log("Auth error detected, middleware will handle redirect");
} else {
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: any) {
console.error("Error polling new messages:", error);
// Silently fail on auth errors during polling - don't disrupt the user
if (error?.message?.includes('401') || error?.message?.includes('403')) {
console.log("Auth error during polling, stopping poll");
return;
}
} 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>
);
}