'use client'; import { useState, useRef, useEffect, useCallback } from 'react'; import Image from 'next/image'; import { useSession } from 'next-auth/react'; import { ChatHeader } from './ChatHeader'; import { MessageInput } from './MessageInput'; import { ChatDropdownMenu, type DropdownMenuItem } from './ChatDropdownMenu'; import { NewConversationModal, type ConnectedContact } from './NewConversationModal'; import { ReportUserModal } from './ReportUserModal'; import { useMessaging } from '@/hooks/useMessaging'; import { connectionRequestsService, type ConnectionRequest } from '@/services/connection-requests.service'; import { uploadService } from '@/services/upload.service'; import type { Conversation, Message, MessageType } from '@/types/messaging'; // Helper to get a valid avatar URL (must start with http://, https://, or /) function getValidAvatarUrl(avatar: string | null | undefined): string { const placeholder = '/assets/icons/user-placeholder-icon.svg'; if (!avatar) return placeholder; // Check if it's a valid URL format if (avatar.startsWith('http://') || avatar.startsWith('https://') || avatar.startsWith('/')) { return avatar; } // Invalid URL format (like raw storage key), use placeholder return placeholder; } // Custom scrollbar styles const customScrollbarStyles = ` .custom-scrollbar::-webkit-scrollbar { width: 8px; } .custom-scrollbar::-webkit-scrollbar-track { background: transparent; } .custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(0, 41, 61, 0.5); border-radius: 10px; } .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(0, 41, 61, 0.7); } `; // Helper to format date for conversation list function formatConversationDate(dateString: string | null): string { if (!dateString) return ''; const date = new Date(dateString); const now = new Date(); const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); if (diffDays === 0) { return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } else if (diffDays === 1) { return 'Yesterday'; } else if (diffDays < 7) { return date.toLocaleDateString('en-US', { weekday: 'short' }); } else { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } } // Helper to format message timestamp function formatMessageTime(dateString: string): string { const date = new Date(dateString); return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } // Helper to get last seen text function formatLastSeen(lastSeenAt: string | null | undefined, isOnline: boolean): string { if (isOnline) return 'Online'; if (!lastSeenAt) return 'Offline'; const date = new Date(lastSeenAt); const now = new Date(); const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60)); if (diffHours < 1) return 'Last seen recently'; if (diffHours < 24) return `${diffHours}h ago`; const diffDays = Math.floor(diffHours / 24); if (diffDays === 1) return 'Last seen yesterday'; return `Last seen ${diffDays}d ago`; } // Avatar component with shimmer loading animation and initials fallback function AvatarWithPlaceholder({ src, alt, size, name }: { src: string; alt: string; size: number; name?: string }) { const [loaded, setLoaded] = useState(false); const [imgError, setImgError] = useState(false); const placeholder = '/assets/icons/user-placeholder-icon.svg'; const isPlaceholder = !src || src === placeholder; const initial = name?.[0]?.toUpperCase() || '?'; return ( <> {/* Initials base layer - always rendered behind image */}
{initial}
{src && !isPlaceholder && !imgError && ( { if (el?.complete && el.naturalWidth > 0) setLoaded(true); }} src={src} alt={alt} className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`} onLoad={() => setLoaded(true)} onError={() => setImgError(true)} /> )} ); } // Connected contact with avatar URL (used for both user-side agents and agent-side users) interface ConnectedAgent { agentProfileId: string; userId?: string; // The user's ID (for agent-side contacts) name: string; avatar: string | null; avatarUrl: string | null; headline: string | null; email: string | null; city: string | null; state: string | null; } export function MessagingPage({ initialConversationId }: { initialConversationId?: string | null }) { const { data: session } = useSession(); const { conversations, currentConversation, messages, isConnected, isLoading, isLoadingMessages, typingUsers, selectConversation, sendMessage, startTyping, stopTyping, loadMoreMessages, hasMoreMessages, startConversation, markAllAsRead, deleteConversation, clearMessages, toggleMute, toggleFavorite, } = useMessaging(); const [searchQuery, setSearchQuery] = useState(''); const [isPageMenuOpen, setIsPageMenuOpen] = useState(false); const [isAllMuted, setIsAllMuted] = useState(false); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [isUploading, setIsUploading] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false); const [connectedAgents, setConnectedAgents] = useState([]); const [isLoadingAgents, setIsLoadingAgents] = useState(false); const [isStartingConversation, setIsStartingConversation] = useState(null); const [isComposeOpen, setIsComposeOpen] = useState(false); const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [showMobileChat, setShowMobileChat] = useState(false); // Store converted avatar URLs by conversation ID const [conversationAvatarUrls, setConversationAvatarUrls] = useState>({}); // Store converted file URLs by message ID (for S3-stored images/files) const [messageFileUrls, setMessageFileUrls] = useState>({}); const messagesContainerRef = useRef(null); const messagesEndRef = useRef(null); const justSwitchedConversationRef = useRef(false); const currentUserId = session?.user?.id; const userRole = session?.user?.role; // Fetch connected users/agents (accepted connections) based on role useEffect(() => { const fetchConnectedContacts = async () => { if (!session || !userRole) return; setIsLoadingAgents(true); try { // USER sees agents, AGENT sees users if (userRole === 'USER') { const requests = await connectionRequestsService.getMyRequests('ACCEPTED'); // Map to connected agents with avatar URLs const agents: ConnectedAgent[] = await Promise.all( requests.map(async (req) => { const agent = req.agentProfile; let avatarUrl: string | null = null; if (agent?.avatar) { try { avatarUrl = await uploadService.getPresignedDownloadUrl(agent.avatar); } catch { avatarUrl = null; } } return { agentProfileId: req.agentProfileId, name: agent ? `${agent.firstName || ''} ${agent.lastName || ''}`.trim() || 'Agent' : 'Agent', avatar: agent?.avatar || null, avatarUrl, headline: agent?.headline || null, email: null, city: agent?.city || null, state: agent?.state || null, }; }) ); setConnectedAgents(agents); } else { // AGENT role — fetch accepted connections (users who connected with this agent) const requests = await connectionRequestsService.getReceivedRequests('ACCEPTED'); const users: ConnectedAgent[] = await Promise.all( requests.map(async (req) => { const user = req.user; const profile = user?.userProfile; let avatarUrl: string | null = null; const avatarKey = profile?.avatar || user?.avatar; if (avatarKey) { try { avatarUrl = await uploadService.getPresignedDownloadUrl(avatarKey); } catch { avatarUrl = null; } } const fullName = profile ? `${profile.firstName || ''} ${profile.lastName || ''}`.trim() : ''; return { agentProfileId: req.agentProfileId, userId: req.userId, name: fullName || user?.email || 'User', avatar: avatarKey || null, avatarUrl, headline: null, email: user?.email || null, city: null, state: null, }; }) ); setConnectedAgents(users); } } catch (error) { console.error('Failed to fetch connected contacts:', error); setConnectedAgents([]); } finally { setIsLoadingAgents(false); } }; fetchConnectedContacts(); }, [session, userRole]); // Convert conversation avatar storage keys to presigned URLs useEffect(() => { const convertAvatarUrls = async () => { const newAvatarUrls: Record = {}; for (const conversation of conversations) { const avatar = conversation.otherParty?.avatar; if (!avatar) continue; // Skip if already converted or if it's already a valid URL if (conversationAvatarUrls[conversation.id]) { newAvatarUrls[conversation.id] = conversationAvatarUrls[conversation.id]; continue; } // Check if it's already a valid URL format if (avatar.startsWith('http://') || avatar.startsWith('https://') || avatar.startsWith('/')) { newAvatarUrls[conversation.id] = avatar; continue; } // Convert storage key to presigned URL try { const presignedUrl = await uploadService.getPresignedDownloadUrl(avatar); newAvatarUrls[conversation.id] = presignedUrl; } catch { // Keep as null/undefined if conversion fails } } if (Object.keys(newAvatarUrls).length > 0) { setConversationAvatarUrls(prev => ({ ...prev, ...newAvatarUrls })); } }; if (conversations.length > 0) { convertAvatarUrls(); } }, [conversations]); // Auto-select conversation when navigated with initialConversationId const initialConvHandledRef = useRef(null); useEffect(() => { if ( initialConversationId && initialConvHandledRef.current !== initialConversationId && conversations.length > 0 && !isLoading ) { initialConvHandledRef.current = initialConversationId; selectConversation(initialConversationId); setShowMobileChat(true); } }, [initialConversationId, conversations, isLoading, selectConversation]); // Convert current conversation avatar when selected useEffect(() => { const convertCurrentAvatar = async () => { if (!currentConversation) return; const avatar = currentConversation.otherParty?.avatar; if (!avatar) return; // Skip if already converted if (conversationAvatarUrls[currentConversation.id]) return; // Check if it's already a valid URL format if (avatar.startsWith('http://') || avatar.startsWith('https://') || avatar.startsWith('/')) { setConversationAvatarUrls(prev => ({ ...prev, [currentConversation.id]: avatar })); return; } // Convert storage key to presigned URL try { const presignedUrl = await uploadService.getPresignedDownloadUrl(avatar); setConversationAvatarUrls(prev => ({ ...prev, [currentConversation.id]: presignedUrl })); } catch { // Keep as null/undefined if conversion fails } }; convertCurrentAvatar(); }, [currentConversation]); // Convert S3 keys to presigned download URLs for message attachments useEffect(() => { const isS3Key = (val: string) => val && !val.startsWith('http://') && !val.startsWith('https://') && !val.startsWith('/') && !val.startsWith('data:'); const isUrl = (val: string) => val.startsWith('http://') || val.startsWith('https://'); const convertFileUrls = async () => { for (const msg of messages) { if (msg.messageType !== 'IMAGE' && msg.messageType !== 'FILE') continue; if (messageFileUrls[msg.id]) continue; // Determine the source: fileUrl or content (some messages store S3 key in content) const source = msg.fileUrl || msg.content; if (!source) continue; if (isUrl(source)) { setMessageFileUrls(prev => ({ ...prev, [msg.id]: source })); continue; } if (isS3Key(source)) { try { const presignedUrl = await uploadService.getPresignedDownloadUrl(source); setMessageFileUrls(prev => ({ ...prev, [msg.id]: presignedUrl })); } catch { // Keep original if conversion fails } } } }; if (messages.length > 0) { convertFileUrls(); } }, [messages]); // Clear cached file URLs when switching conversations useEffect(() => { setMessageFileUrls({}); }, [currentConversation?.id]); // Handle starting a new conversation — role-aware const handleStartConversation = useCallback(async (contactId: string) => { setIsStartingConversation(contactId); try { const body = userRole === 'AGENT' ? { userId: contactId } : { agentProfileId: contactId }; const conversation = await startConversation(body); await selectConversation(conversation.id); } catch (error) { console.error('Failed to start conversation:', error); } finally { setIsStartingConversation(null); } }, [startConversation, selectConversation, userRole]); // Handle sending a message const handleSendMessage = useCallback(async (content: string) => { if (!content.trim()) return; await sendMessage(content); }, [sendMessage]); // Handle sending a GIF const handleSendGif = useCallback(async (gifUrl: string) => { await sendMessage(gifUrl, 'IMAGE' as MessageType, { fileUrl: gifUrl, mimeType: 'image/gif' }); }, [sendMessage]); // Handle sending a file (image or document) const handleSendFile = useCallback(async (file: File, messageType: 'IMAGE' | 'FILE') => { setIsUploading(true); try { const uploaded = await uploadService.uploadMessageFile(file); // uploaded.url is the S3 key (not a full URL) const content = messageType === 'IMAGE' ? 'Sent an image' : 'Sent a file'; await sendMessage( content, messageType as MessageType, { fileUrl: uploaded.url, mimeType: file.type, fileName: file.name, fileSize: file.size, } ); } catch (error) { console.error('Failed to upload file:', error); } finally { setIsUploading(false); } }, [sendMessage]); // Handle typing events const handleTypingStart = useCallback(() => { startTyping(); }, [startTyping]); const handleTypingStop = useCallback(() => { stopTyping(); }, [stopTyping]); // Handle delete conversation with confirmation const handleDeleteConversation = useCallback(async () => { if (!currentConversation) return; setConfirmDeleteId(currentConversation.id); }, [currentConversation]); const confirmDelete = useCallback(async () => { if (!confirmDeleteId) return; try { await deleteConversation(confirmDeleteId); } catch (error) { console.error('Failed to delete conversation:', error); } setConfirmDeleteId(null); }, [confirmDeleteId, deleteConversation]); // Page header menu items const pageMenuItems: DropdownMenuItem[] = [ { label: 'Mark All as Read', onClick: () => { markAllAsRead().catch(console.error); }, }, { label: isAllMuted ? 'Unmute All Notifications' : 'Mute All Notifications', onClick: () => setIsAllMuted((prev) => !prev), }, ]; // Handle scroll events const handleScroll = () => { if (messagesContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; // Show button if not at bottom (with 100px threshold) setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100); // Load more messages when scrolled to top if (scrollTop === 0 && hasMoreMessages && !isLoadingMessages) { loadMoreMessages(); } } }; const scrollToBottom = () => { if (messagesContainerRef.current) { messagesContainerRef.current.scrollTo({ top: messagesContainerRef.current.scrollHeight, behavior: 'smooth' }); } }; // Mark that we switched conversations so we scroll after messages load useEffect(() => { justSwitchedConversationRef.current = true; }, [currentConversation?.id]); // Scroll to bottom when messages load after opening a chat, or when a new message arrives useEffect(() => { if (messages.length > 0) { // After opening/switching a conversation — scroll once messages are rendered if (justSwitchedConversationRef.current) { justSwitchedConversationRef.current = false; setTimeout(scrollToBottom, 50); return; } // New incoming message — scroll if it's very recent const lastMessage = messages[messages.length - 1]; const isVeryRecent = new Date().getTime() - new Date(lastMessage.createdAt).getTime() < 2000; if (isVeryRecent) { scrollToBottom(); } } }, [messages]); // Scroll to bottom when typing indicator appears useEffect(() => { if (typingUsers.size > 0) { scrollToBottom(); } }, [typingUsers]); // Filter conversations by search const filteredConversations = conversations.filter((conv) => conv.otherParty?.name?.toLowerCase().includes(searchQuery.toLowerCase()) ); // Build selected user info for ChatHeader const selectedUserInfo = currentConversation ? { name: currentConversation.otherParty?.name || 'Unknown', role: currentConversation.otherParty?.headline || 'Agent', lastSeen: formatLastSeen(currentConversation.otherParty?.lastSeenAt, currentConversation.otherParty?.isOnline), avatar: getValidAvatarUrl(conversationAvatarUrls[currentConversation.id]), isOnline: currentConversation.otherParty?.isOnline || false, } : null; // Get typing user names const typingUserNames = Array.from(typingUsers) .filter(userId => userId !== currentUserId) .map(userId => { // The other party is typing if (currentConversation?.otherParty.userId === userId || currentConversation?.otherParty.id === userId) { return currentConversation.otherParty.name.split(' ')[0]; } return 'Someone'; }); return ( <> {/* Delete confirmation dialog */} {confirmDeleteId && (

Delete Conversation

Are you sure you want to delete this conversation? This action cannot be undone and all messages will be permanently removed.

)} {/* New conversation modal */} setIsComposeOpen(false)} contacts={connectedAgents.map((agent) => ({ id: userRole === 'AGENT' ? agent.userId! : agent.agentProfileId, name: agent.name, avatar: agent.avatar, avatarUrl: agent.avatarUrl, headline: agent.headline, email: agent.email, city: agent.city, state: agent.state, }))} isLoading={isLoadingAgents} onStartConversation={async (contactId) => { await handleStartConversation(contactId); }} /> {/* Report user modal */} {currentConversation && ( setIsReportModalOpen(false)} reportedUserId={currentConversation.otherParty?.userId || currentConversation.otherParty?.id || ''} reportedUserName={currentConversation.otherParty?.name || 'User'} conversationId={currentConversation.id} /> )}
{/* Header */}

Messaging

{/* Connection status indicator */}
{/* Search bar */}
Search setSearchQuery(e.target.value)} placeholder="Search Message" className="flex-1 font-serif font-normal text-[18px] leading-[24px] text-[#00293D] placeholder-[#00293D]/50 focus:outline-none" />
{/* Header actions */}
setIsPageMenuOpen(false)} />
{/* Main content */}
{/* Left sidebar - Conversation list (hidden on mobile when chat is open) */}
{isLoading || isLoadingAgents ? (
) : filteredConversations.length === 0 ? ( /* Show connected agents when no conversations */
{/* Header for connected agents */}

{searchQuery ? 'No conversations found' : `No conversations yet. Start a conversation with ${userRole === 'AGENT' ? 'a user' : 'an agent'}!`}

{/* List of connected agents */} {connectedAgents.length > 0 ? (

{userRole === 'AGENT' ? 'Your Connected Users' : 'Your Connected Agents'}

{connectedAgents .filter((agent) => agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ) .map((agent) => ( ))}
) : (

{userRole === 'AGENT' ? 'No connected users yet.' : 'No connected agents yet. Connect with agents to start messaging!'}

)}
) : (
{filteredConversations.map((conversation) => (
{ selectConversation(conversation.id); setShowMobileChat(true); }} className="relative flex items-start gap-3 p-4 rounded-[15px] cursor-pointer transition-colors hover:bg-[#00293d]/5" style={{ border: '1px solid rgba(0, 41, 61, 0.1)' }} > {/* Active indicator line */} {currentConversation?.id === conversation.id && (
)} {/* Avatar with online indicator */}
{conversation.otherParty?.isOnline && (
)}
{/* Content */}
{(userRole === 'AGENT' ? conversation.agentFavorited : conversation.userFavorited) && ( Starred )}

{conversation.otherParty?.name || 'Unknown'}

{(userRole === 'AGENT' ? conversation.agentMuted : conversation.userMuted) && ( )} {conversation.unreadCount > 0 && ( {conversation.unreadCount} )}

{conversation.lastMessageText ? /giphy\.com|\.gif(\?|$)/i.test(conversation.lastMessageText) ? 'GIF 🎬' : /\.(jpg|jpeg|png|webp|svg)(\?|$)/i.test(conversation.lastMessageText) && conversation.lastMessageText.startsWith('http') ? 'Image 📷' : conversation.lastMessageText : 'No messages yet'}

{/* Date */}

{formatConversationDate(conversation.lastMessageAt)}

))}
)}
{/* Right panel - Chat area (full width on mobile, hidden when no conversation on mobile) */}
{currentConversation && selectedUserInfo ? ( <> {/* Mobile back button */} {/* Chat header */} 0} typingText={typingUserNames.length > 0 ? `${typingUserNames.join(', ')} is typing...` : undefined} isMuted={userRole === 'AGENT' ? (currentConversation?.agentMuted || false) : (currentConversation?.userMuted || false)} isStarred={userRole === 'AGENT' ? (currentConversation?.agentFavorited || false) : (currentConversation?.userFavorited || false)} onClearChat={clearMessages} onDeleteConversation={handleDeleteConversation} onReportUser={() => setIsReportModalOpen(true)} onToggleMute={(muted) => currentConversation && toggleMute(currentConversation.id, muted)} onToggleStar={(starred) => currentConversation && toggleFavorite(currentConversation.id, starred)} /> {/* Messages area wrapper */}
{isLoadingMessages && messages.length === 0 ? (
) : (
{/* Load more indicator */} {isLoadingMessages && messages.length > 0 && (
)}
{messages.map((message) => { const isOwn = message.senderId === currentUserId; return (
{message.messageType === 'IMAGE' ? ( messageFileUrls[message.id] ? ( <> {message.fileName window.open(messageFileUrls[message.id], '_blank')} onError={(e) => { const target = e.target as HTMLImageElement; target.style.display = 'none'; const fallback = target.nextElementSibling; if (fallback) (fallback as HTMLElement).style.display = 'flex'; }} />
Image {message.fileName || 'Image'}
) : (
) ) : message.messageType === 'FILE' ? (
File

{message.fileName || message.content}

{message.fileSize && (

{message.fileSize < 1024 ? `${message.fileSize} B` : message.fileSize < 1024 * 1024 ? `${(message.fileSize / 1024).toFixed(1)} KB` : `${(message.fileSize / (1024 * 1024)).toFixed(1)} MB`}

)}
) : (

{message.content}

)}

{formatMessageTime(message.createdAt)}

{isOwn && ( {message.status === 'READ' ? ( Read ) : message.status === 'DELIVERED' ? ( Delivered ) : ( Sent )} )}
); })} {/* Typing indicator in messages */} {typingUserNames.length > 0 && (
{formatMessageTime(new Date().toISOString())}
)}
)} {/* Scroll to bottom button */} {showScrollButton && ( )}
{/* Message input */} ) : (
Messages

Select a conversation to start messaging

)}
); }