'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 { 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
function AvatarWithPlaceholder({ src, alt, size }: { src: string; alt: string; size: number }) {
const [loaded, setLoaded] = useState(false);
const placeholder = '/assets/icons/user-placeholder-icon.svg';
const isPlaceholder = !src || src === placeholder;
return (
<>
{(!src || !loaded) && (
)}
{src && !isPlaceholder && (
setLoaded(true)}
/>
)}
{src && isPlaceholder && (
)}
>
);
}
// Connected agent with avatar URL
interface ConnectedAgent {
agentProfileId: string;
name: string;
avatar: string | null;
avatarUrl: string | null;
headline: string | null;
city: string | null;
state: string | null;
}
export function MessagingPage() {
const { data: session } = useSession();
const {
conversations,
currentConversation,
messages,
isConnected,
isLoading,
isLoadingMessages,
typingUsers,
selectConversation,
sendMessage,
startTyping,
stopTyping,
loadMoreMessages,
hasMoreMessages,
startConversation,
markAllAsRead,
deleteConversation,
clearMessages,
} = 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);
// 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,
city: agent?.city || null,
state: agent?.state || null,
};
})
);
setConnectedAgents(agents);
} else {
// For AGENT role - they don't start conversations, they receive them
// So we don't need to show a list to start conversations
setConnectedAgents([]);
}
} 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]);
// 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 convertFileUrls = async () => {
for (const msg of messages) {
if ((msg.messageType === 'IMAGE' || msg.messageType === 'FILE') && msg.fileUrl) {
// Skip if already converted
if (messageFileUrls[msg.id]) continue;
// Skip if it's already an accessible URL (e.g., Giphy GIF URLs)
if (msg.fileUrl.startsWith('http://') || msg.fileUrl.startsWith('https://')) {
setMessageFileUrls(prev => ({ ...prev, [msg.id]: msg.fileUrl! }));
continue;
}
// Convert S3 key to presigned download URL
try {
const presignedUrl = await uploadService.getPresignedDownloadUrl(msg.fileUrl);
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 with an agent
const handleStartConversation = useCallback(async (agentProfileId: string) => {
setIsStartingConversation(agentProfileId);
try {
const conversation = await startConversation(agentProfileId);
await selectConversation(conversation.id);
} catch (error) {
console.error('Failed to start conversation:', error);
} finally {
setIsStartingConversation(null);
}
}, [startConversation, selectConversation]);
// 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.
)}
{/* Header */}
Messaging
{/* Connection status indicator */}
{/* Search bar */}
{/* Header actions */}
setIsPageMenuOpen(false)}
/>
{/* Main content */}
{/* Left sidebar - Conversation list */}
{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 an agent!'}
{/* List of connected agents */}
{connectedAgents.length > 0 ? (
Your Connected Agents
{connectedAgents
.filter((agent) =>
agent.name.toLowerCase().includes(searchQuery.toLowerCase())
)
.map((agent) => (
))}
) : (
No connected agents yet. Connect with agents to start messaging!
)}
) : (
{filteredConversations.map((conversation) => (
selectConversation(conversation.id)}
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 */}
{conversation.otherParty?.name || 'Unknown'}
{conversation.unreadCount > 0 && (
{conversation.unreadCount}
)}
{conversation.lastMessageText || 'No messages yet'}
{/* Date */}
{formatConversationDate(conversation.lastMessageAt)}
))}
)}
{/* Right panel - Chat area */}
{currentConversation && selectedUserInfo ? (
<>
{/* Chat header */}
0}
typingText={typingUserNames.length > 0 ? `${typingUserNames.join(', ')} is typing...` : undefined}
onClearChat={clearMessages}
onDeleteConversation={handleDeleteConversation}
/>
{/* Messages area wrapper */}
{isLoadingMessages && messages.length === 0 ? (
) : (
{/* Load more indicator */}
{isLoadingMessages && messages.length > 0 && (
)}
{messages.map((message) => {
const isOwn = message.senderId === currentUserId;
return (
);
})}
{/* Typing indicator in messages */}
{typingUserNames.length > 0 && (
{formatMessageTime(new Date().toISOString())}
)}
)}
{/* Scroll to bottom button */}
{showScrollButton && (
)}
{/* Message input */}
>
) : (
Select a conversation to start messaging
)}
>
);
}