![]() Server : Apache/2 System : Linux server-15-235-50-60 5.15.0-164-generic #174-Ubuntu SMP Fri Nov 14 20:25:16 UTC 2025 x86_64 User : gositeme ( 1004) PHP Version : 8.2.29 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname Directory : /home/gositeme/domains/lavocat.ca/public_html/src/components/Chat/ |
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { motion, AnimatePresence } from 'framer-motion';
import { format, formatDistanceToNow } from 'date-fns';
import { useWebSocket } from '../../context/StableWebSocketContext';
import DocumentViewer from '../DocumentViewer';
import FileUpload from '../ui/FileUpload';
import { toast } from 'react-hot-toast';
// import VideoCall from './VideoCall'; // ✅ DISABLED - Using global SimpleVideoCall
import Peer from 'simple-peer';
import UserAvatar from '../UserAvatar';
import ProfilePopover from '../ProfilePopover';
interface PrivateMessage {
id: string;
content: string;
type: string;
fileUrl?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
createdAt: string;
sender: {
id: string;
name: string;
email: string;
role: string;
profilePicture?: string;
title?: string;
specialization?: string;
availability?: string;
lastActive?: string;
bio?: string;
};
}
interface PrivateChat {
id: string;
registrationId: string;
userId: string;
adminId?: string;
messages: PrivateMessage[];
registration: {
id: string;
firstName: string;
lastName: string;
email: string;
userId: string;
documents?: {
id: string;
name: string;
url: string;
type: string;
}[];
};
}
interface PrivateChatProps {
registrationId: string;
onClose: () => void;
}
const PrivateChat = ({ registrationId, onClose }: PrivateChatProps) => {
console.log('[PrivateChat] 🚀 Component mounted with registrationId:', registrationId);
const { data: session } = useSession();
const { ws, connected } = useWebSocket();
const [chat, setChat] = useState<PrivateChat | null>(null);
const [newMessage, setNewMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
const [showFileUpload, setShowFileUpload] = useState(false);
const [showDocumentPicker, setShowDocumentPicker] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedDocument, setSelectedDocument] = useState<{
url: string;
type: string;
name: string;
} | null>(null);
const [isUploading, setIsUploading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Video Call State
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [callInProgress, setCallInProgress] = useState(false);
const [incomingCall, setIncomingCall] = useState<{ senderId: string; signal: any; } | null>(null);
const peerRef = useRef<Peer.Instance | null>(null);
// Profile popover state
const [profilePopover, setProfilePopover] = useState<{ userId: string; isOpen: boolean } | null>(null);
const getRecipientId = () => {
if (!chat || !session) return null;
return session.user.role === 'ADMIN' ? chat.registration.userId : chat.adminId;
};
// Request notification permission on component mount
useEffect(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then(permission => {
console.log('[PrivateChat] Notification permission:', permission);
});
}
}, []);
// WebSocket message handling
useEffect(() => {
if (!ws) return;
const handleMessage = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
console.log('Private chat WebSocket message received:', message);
switch (message.type) {
case 'PRIVATE_MESSAGE':
if (message.data.registrationId === registrationId) {
// Prevent duplicate messages from WebSocket for messages sent by current user
const isOwnMessage = message.data.message.sender.id === session?.user?.id;
const messageExists = chat?.messages.some(msg => msg.id === message.data.message.id);
if (!isOwnMessage && !messageExists) {
setChat(prev => prev ? { ...prev, messages: [...prev.messages, message.data.message] } : null);
}
// Show notification for incoming messages from other users
if (!isOwnMessage) {
const isTabVisible = !document.hidden;
// Show notification if tab is not visible or focused
if (!isTabVisible) {
// Show toast notification
toast((t) => (
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">
{message.data.message.sender.name?.charAt(0) || 'U'}
</span>
</div>
</div>
<div className="flex-1">
<p className="font-medium text-gray-900">
💬 {message.data.message.sender.name || 'User'}
</p>
<p className="text-sm text-gray-600 truncate max-w-48">
{message.data.message.content}
</p>
</div>
</div>
), {
duration: 4000,
position: 'top-right',
style: {
maxWidth: '350px',
},
});
// Show browser notification
if (Notification.permission === 'granted') {
new Notification(`New message from ${message.data.message.sender.name || 'User'}`, {
body: message.data.message.content,
icon: '/icons/apple-touch-icon-180x180.png'
});
}
console.log(`[PrivateChat] 🔔 Notification shown for message from ${message.data.message.sender.name}`);
}
}
}
break;
case 'TYPING':
if (message.data.registrationId === registrationId) {
setTypingUsers(prev => {
const newSet = new Set(prev);
if (message.data.isTyping) newSet.add(message.data.userName);
else newSet.delete(message.data.userName);
return newSet;
});
}
break;
// WebRTC Signaling
case 'webrtc-offer':
setIncomingCall({ senderId: message.senderId, signal: message.data.signal });
break;
case 'webrtc-answer':
if (peerRef.current) {
peerRef.current.signal(message.data.signal);
}
break;
case 'webrtc-ice-candidate':
if (peerRef.current) {
peerRef.current.signal(message.data.candidate);
}
break;
case 'webrtc-end-call':
endCallCleanup();
break;
}
} catch (error) {
console.error('Error parsing private chat WebSocket message:', error);
}
};
ws.addEventListener('message', handleMessage);
return () => ws.removeEventListener('message', handleMessage);
}, [ws, registrationId, session?.user?.id]);
const fetchChat = async () => {
try {
setIsLoading(true);
const response = await fetch(`/api/chat/private/${registrationId}/messages`, {
method: 'GET',
credentials: 'same-origin',
});
if (!response.ok) throw new Error('Failed to fetch chat');
const data = await response.json();
setChat(data);
scrollToBottom();
} catch (err) {
setError('Failed to load chat');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
console.log('[PrivateChat] 🔄 registrationId effect triggered:', registrationId);
if (registrationId) {
console.log('[PrivateChat] 📞 Fetching chat for registration:', registrationId);
fetchChat();
}
}, [registrationId]);
// Handle typing
const handleTyping = useCallback((isTyping: boolean) => {
if (!ws || !connected || !session?.user?.name) return;
setIsTyping(isTyping);
try {
ws.send(JSON.stringify({
type: 'TYPING',
data: {
registrationId,
userName: session.user.name,
isTyping
}
}));
} catch (error) {
console.error('Failed to send typing status:', error);
}
// Clear typing indicator after delay
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (isTyping) {
typingTimeoutRef.current = setTimeout(() => {
handleTyping(false);
}, 3000);
}
}, [ws, connected, registrationId]);
const sendMessage = async () => {
if (!newMessage.trim() || !chat || !session?.user) return;
const messageContent = newMessage.trim();
setNewMessage('');
handleTyping(false);
// Create optimistic message
const optimisticMessage: PrivateMessage = {
id: `temp-${Date.now()}`,
content: messageContent,
type: 'TEXT',
createdAt: new Date().toISOString(),
sender: {
id: session.user.id,
name: session.user.name || 'Unknown',
email: session.user.email || '',
role: session.user.role || 'USER'
}
};
// Add to messages immediately (optimistic update)
setChat(prev => prev ? {
...prev,
messages: [...prev.messages, optimisticMessage]
} : null);
try {
const response = await fetch(`/api/chat/private/${registrationId}/messages`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: messageContent })
});
if (!response.ok) {
let errorMsg = 'Failed to send message';
try {
const errorData = await response.json();
if (errorData && (errorData.error || errorData.message)) {
errorMsg = errorData.error || errorData.message;
}
} catch (e) {
// fallback to text if not JSON
try {
const errorText = await response.text();
if (errorText) errorMsg = errorText;
} catch {}
}
console.error('Send message error:', errorMsg);
throw new Error(errorMsg);
}
const savedMessage = await response.json();
// Replace optimistic message with real one
setChat(prev => prev ? {
...prev,
messages: prev.messages.map(msg =>
msg.id === optimisticMessage.id ? savedMessage : msg
)
} : null);
// Note: Server handles WebSocket broadcast after receiving this HTTP POST
// No need to manually broadcast here to avoid duplication
} catch (error) {
console.error('Error sending private message:', error);
// Remove optimistic message on error
setChat(prev => prev ? {
...prev,
messages: prev.messages.filter(msg => msg.id !== optimisticMessage.id)
} : null);
setError(
error instanceof Error &&
typeof error.message === 'string' &&
error.message.toLowerCase().includes('message limit')
? "You’ve reached the message limit. Please wait for an admin to reply before sending more messages."
: error instanceof Error
? error.message
: 'Failed to send message. Please try again.'
);
}
};
const handleFileUpload = async () => {
if (!selectedFile || !chat || !session) return;
setIsUploading(true);
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('userId', session.user.id);
formData.append('registrationId', registrationId);
try {
const response = await fetch('/api/user/upload', {
method: 'POST',
credentials: 'same-origin',
body: formData,
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
console.error('Upload error response:', errorData);
} catch (parseError) {
console.error('Failed to parse error response:', parseError);
errorData = {};
}
console.error('Response status:', response.status);
console.error('Response status text:', response.statusText);
console.error('Response headers:', Object.fromEntries(response.headers.entries()));
throw new Error(errorData.message || errorData.error || `File upload failed (${response.status}: ${response.statusText})`);
}
const newDocument = await response.json();
// Optimistically update UI
const optimisticMessage: PrivateMessage = {
id: `temp-doc-${Date.now()}`,
content: `Shared a document: ${newDocument.name}`,
type: 'DOCUMENT',
fileUrl: newDocument.url,
fileName: newDocument.name,
mimeType: newDocument.type,
createdAt: new Date().toISOString(),
sender: {
id: session.user.id,
name: session.user.name || 'Unknown',
email: session.user.email || '',
role: session.user.role || 'USER',
},
};
setChat(prev => prev ? { ...prev, messages: [...prev.messages, optimisticMessage] } : null);
setShowFileUpload(false);
setSelectedFile(null);
} catch (error) {
console.error('File upload error:', error);
setError((error as Error).message);
} finally {
setIsUploading(false);
}
};
const handleDocumentShare = async (document: { id: string; name: string; url: string; type: string }) => {
if (!chat || !session) return;
const message = {
id: `temp-doc-share-${Date.now()}`,
content: `Shared a document: ${document.name}`,
type: 'DOCUMENT',
fileUrl: document.url,
fileName: document.name,
mimeType: document.type,
createdAt: new Date().toISOString(),
sender: {
id: session.user.id,
name: session.user.name || "Unknown",
email: session.user.email || "",
role: session.user.role || "USER",
},
};
setChat(prev => prev ? { ...prev, messages: [...prev.messages, message] } : null);
setShowDocumentPicker(false);
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({
type: 'PRIVATE_MESSAGE',
data: {
registrationId,
message,
}
}));
} catch (error) {
console.error('Error broadcasting shared document:', error);
}
}
};
const getFileType = (mimeType: string) => {
if (mimeType.startsWith('image/')) return 'IMAGE';
if (mimeType.startsWith('video/')) return 'VIDEO';
if (mimeType.startsWith('audio/')) return 'AUDIO';
return 'FILE';
};
const scrollToBottom = () => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
};
// Scroll to bottom when messages change
useEffect(() => {
scrollToBottom();
}, [chat?.messages]);
const renderMessage = (message: PrivateMessage, index: number) => {
const isSender = message.sender.id === session?.user?.id;
const prevMessage = chat?.messages[index - 1];
const showAvatar = !isSender && (!prevMessage || prevMessage.sender.id !== message.sender.id);
const isGroupStart = !prevMessage || prevMessage.sender.id !== message.sender.id;
const userName = message.sender?.name || 'Unknown User';
const messageTime = format(new Date(message.createdAt), 'HH:mm');
return (
<div key={message.id} className={`flex items-start gap-3 ${isGroupStart ? 'mt-4' : 'mt-1'}`}>
<div className="w-10 flex-shrink-0">
{showAvatar && (
<div className="relative">
<UserAvatar
user={message.sender}
size="sm"
showStatus={true}
clickable={true}
onClick={() => setProfilePopover({
userId: message.sender.id,
isOpen: profilePopover?.userId !== message.sender.id || !profilePopover?.isOpen
})}
/>
{profilePopover?.userId === message.sender.id && profilePopover?.isOpen && (
<ProfilePopover
userId={message.sender.id}
isOpen={true}
onClose={() => setProfilePopover(null)}
position="right"
/>
)}
</div>
)}
</div>
<div className={`max-w-lg ${isSender ? 'ml-auto' : ''}`}>
{isGroupStart && (
<div className={`flex items-center gap-2 mb-1 ${isSender ? 'justify-end' : 'justify-start'}`}>
<p className={`text-xs font-medium ${isSender ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`}>
{isSender ? 'You' : (
<span className="flex items-center gap-1">
{message.sender.role === 'ADMIN' ? '⚖️ ' : ''}{userName}
{message.sender.title && (
<span className="text-gray-500 dark:text-gray-400">• {message.sender.title}</span>
)}
</span>
)}
</p>
<span className="text-xs text-gray-400">{messageTime}</span>
</div>
)}
<div className={`px-4 py-2 rounded-2xl inline-block shadow-sm ${
isSender
? 'bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-br-none'
: 'bg-white text-gray-800 border border-gray-200 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 rounded-bl-none'
}`}>
{message.type === 'FILE' || message.type === 'IMAGE' || message.type === 'VIDEO' || message.type === 'AUDIO' ? (
<div className="space-y-2">
<p className="text-sm">{message.content}</p>
{/* Enhanced image preview for IMAGE type */}
{message.type === 'IMAGE' && message.fileUrl ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setSelectedDocument({
url: message.fileUrl!,
type: message.mimeType!,
name: message.fileName!
})}
>
<img
src={message.fileUrl}
alt={message.fileName}
className="max-w-full max-h-64 object-contain rounded-lg shadow-md"
onError={(e) => {
// Fallback to file display if image fails to load
e.currentTarget.style.display = 'none';
(e.currentTarget.nextElementSibling as HTMLElement).style.display = 'block';
}}
/>
{/* Fallback file display (hidden by default) */}
<div className="bg-gray-100 dark:bg-gray-600 rounded-lg p-3" style={{ display: 'none' }}>
<div className="flex items-center space-x-2">
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{message.fileName}</p>
<p className="text-xs text-gray-500">
{message.fileSize ? `${(message.fileSize / 1024).toFixed(1)} KB` : ''} • Image
</p>
</div>
<span className="text-blue-500 hover:text-blue-700 text-sm font-medium">
View
</span>
</div>
</div>
{/* Click overlay indicator */}
<div className="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-10 transition-all duration-200 flex items-center justify-center">
<div className="bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-sm opacity-0 group-hover:opacity-100 transition-opacity">
Click to enlarge
</div>
</div>
</div>
) : (
/* Regular file display for non-images */
<div className="bg-gray-100 dark:bg-gray-600 rounded-lg p-3">
<div className="flex items-center space-x-2">
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{message.fileName}</p>
<p className="text-xs text-gray-500">
{message.fileSize ? `${(message.fileSize / 1024).toFixed(1)} KB` : ''} • {message.type}
</p>
</div>
<button
onClick={() => setSelectedDocument({
url: message.fileUrl!,
type: message.mimeType!,
name: message.fileName!
})}
className="text-blue-500 hover:text-blue-700 text-sm font-medium"
>
View
</button>
</div>
</div>
)}
</div>
) : (
<p className="text-sm">{message.content}</p>
)}
</div>
{message.id.startsWith('temp-') && (
<p className="text-xs text-right text-gray-400 mt-1 flex items-center justify-end gap-1">
<div className="w-3 h-3 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
Sending...
</p>
)}
</div>
</div>
);
};
const createPeer = (recipientId: string, stream: MediaStream, initiator: boolean) => {
const peer = new Peer({
initiator,
trickle: true,
stream,
});
peer.on('signal', (signal) => {
const type = initiator ? 'webrtc-offer' : 'webrtc-answer';
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, data: { recipientId, signal } }));
}
});
peer.on('stream', (remoteStream) => {
setRemoteStream(remoteStream);
});
peer.on('icecandidate', (candidate) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'webrtc-ice-candidate', data: { recipientId, candidate } }));
}
});
peer.on('close', () => endCallCleanup());
peer.on('error', (err) => {
console.error('Peer error:', err);
endCallCleanup();
});
return peer;
};
const startCall = async () => {
const recipientId = getRecipientId();
if (!recipientId || !ws) return;
setCallInProgress(true);
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
setLocalStream(stream);
peerRef.current = createPeer(recipientId, stream, true);
} catch (err) {
console.error('Failed to get user media', err);
setCallInProgress(false);
}
};
const answerCall = async () => {
if (!incomingCall || !ws) return;
setCallInProgress(true);
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
setLocalStream(stream);
peerRef.current = createPeer(incomingCall.senderId, stream, false);
peerRef.current.signal(incomingCall.signal);
setIncomingCall(null);
} catch (err) {
console.error('Failed to get user media for answering call', err);
setCallInProgress(false);
}
};
const endCallCleanup = () => {
if (peerRef.current) {
peerRef.current.destroy();
peerRef.current = null;
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
setLocalStream(null);
}
setRemoteStream(null);
setCallInProgress(false);
setIncomingCall(null);
};
const endCall = () => {
const recipientId = getRecipientId();
if (ws && recipientId) {
ws.send(JSON.stringify({ type: 'webrtc-end-call', data: { recipientId } }));
}
endCallCleanup();
};
if (isLoading) {
return <div className="text-center p-8">Loading chat...</div>;
}
if (error) {
return <div className="text-center p-8 text-red-500">{error}</div>;
}
if (!chat) {
return <div className="text-center p-8">Chat not found.</div>;
}
// ✅ OLD VIDEO CALL DISABLED - Using global SimpleVideoCall now
if (false) {
return <div>Old video call disabled</div>;
}
if (incomingCall) {
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
<h2 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Incoming Call</h2>
<p className="mb-6 text-gray-700 dark:text-gray-300">You have an incoming video call.</p>
<div className="flex justify-center space-x-4">
<button onClick={answerCall} className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-6 rounded-full transition-colors">
Accept
</button>
<button onClick={() => setIncomingCall(null)} className="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-6 rounded-full transition-colors">
Decline
</button>
</div>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4 font-sans">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl h-[70vh] flex flex-col"
>
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between rounded-t-lg flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-green-500 to-teal-600 text-white flex items-center justify-center text-lg font-bold flex-shrink-0">
{chat.registration.firstName.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-200 truncate">
{chat.registration.firstName} {chat.registration.lastName}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{chat.registration.email}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<div className={`w-2.5 h-2.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} title={connected ? 'Connected' : 'Disconnected'}></div>
{/* Video call removed for now */}
<button
onClick={onClose}
className="p-1 rounded-full text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{chat.messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center text-gray-500 dark:text-gray-400">
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center mb-4">
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h3 className="text-xl font-semibold mb-2">No messages yet</h3>
<p>Start the conversation by sending a message!</p>
</div>
) : (
chat.messages.map(renderMessage)
)}
{/* Typing indicator */}
{typingUsers.size > 0 && (
<div className="flex justify-start">
<div className="bg-white dark:bg-gray-700 rounded-2xl px-4 py-2 rounded-bl-none shadow">
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{Array.from(typingUsers).join(', ')} is typing...
</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
{error && typeof error === 'string' && error.toLowerCase().includes('message limit') ? (
<div className="w-full flex items-center justify-center py-6">
<span className="text-gray-500 text-base font-medium">Waiting for lawyer response…</span>
</div>
) : (
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg px-2 py-1">
<div className="flex items-center space-x-1">
<button
onClick={() => setShowFileUpload(true)}
className="p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
title="Attach File"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
</button>
{session?.user.role === 'ADMIN' && chat.registration.documents && chat.registration.documents.length > 0 && (
<button
onClick={() => setShowDocumentPicker(true)}
className="p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
title="Share Document"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
)}
</div>
<input
type="text"
value={newMessage}
onChange={(e) => {
setNewMessage(e.target.value);
if (!isTyping) handleTyping(true);
}}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="Type a message..."
className="flex-1 bg-transparent border-none focus:ring-0 text-sm px-3 text-gray-900 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400"
/>
<button
onClick={sendMessage}
disabled={!newMessage.trim()}
className="p-2 rounded-full text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
</svg>
</button>
</div>
)}
</div>
</motion.div>
{/* Document/Image Viewer Modal */}
<AnimatePresence>
{selectedDocument && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-75 z-[60] flex items-center justify-center p-4"
onClick={() => setSelectedDocument(null)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-7xl max-h-[90vh] w-full overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{selectedDocument.name}
</h3>
<div className="flex items-center space-x-2">
<a
href={selectedDocument.url}
download={selectedDocument.name}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Download"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
<button
onClick={() => setSelectedDocument(null)}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Close"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="p-4 max-h-[80vh] overflow-y-auto">
<DocumentViewer
url={selectedDocument.url}
type={selectedDocument.type}
name={selectedDocument.name}
/>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* File Upload Modal */}
<AnimatePresence>
{showFileUpload && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-50 z-[60] flex items-center justify-center p-4"
onClick={() => setShowFileUpload(false)}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Upload File</h3>
<button
onClick={() => setShowFileUpload(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<FileUpload
onFileSelect={setSelectedFile}
onRemoveFile={() => setSelectedFile(null)}
selectedFile={selectedFile}
uploading={isUploading}
className="mb-4"
/>
{selectedFile && (
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setShowFileUpload(false);
setSelectedFile(null);
}}
className="px-4 py-2 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
<button
onClick={handleFileUpload}
disabled={isUploading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? 'Uploading...' : 'Send File'}
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default PrivateChat;