feat: Implement real-time messaging functionality with dedicated services, types, a custom hook, and UI integration.

This commit is contained in:
pradeepkumar
2026-02-08 22:44:06 +05:30
parent 8d3fd0b0d2
commit a2384fc7d9
11 changed files with 1585 additions and 305 deletions

View File

@@ -10,6 +10,8 @@ interface ChatHeaderProps {
isOnline?: boolean;
pronouns?: string;
expertise?: string[];
isTyping?: boolean;
typingText?: string;
}
export function ChatHeader({
@@ -20,6 +22,8 @@ export function ChatHeader({
isOnline = false,
pronouns,
expertise = [],
isTyping = false,
typingText,
}: ChatHeaderProps) {
return (
<div className="border-b border-[#00293d]/10 pb-4">
@@ -34,7 +38,11 @@ export function ChatHeader({
{role}
</span>
<span className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D]">
{lastSeen}
{isTyping ? (
<span className="text-[#e58625] italic">{typingText}</span>
) : (
lastSeen
)}
</span>
</div>
<div className="flex items-center gap-2">
@@ -67,7 +75,8 @@ export function ChatHeader({
alt={name}
width={50}
height={50}
className="w-full h-full object-cover rounded-full"
className="object-cover rounded-full"
style={{ width: '100%', height: '100%' }}
/>
</div>
{isOnline && (

View File

@@ -1,17 +1,64 @@
'use client';
import Image from 'next/image';
import { useState } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
interface MessageInputProps {
onSend?: (message: string) => void;
onTypingStart?: () => void;
onTypingStop?: () => void;
}
export function MessageInput({ onSend }: MessageInputProps) {
export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInputProps) {
const [message, setMessage] = useState('');
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isTypingRef = useRef(false);
// Handle typing indicator
const handleTyping = useCallback(() => {
// Start typing indicator
if (!isTypingRef.current && onTypingStart) {
isTypingRef.current = true;
onTypingStart();
}
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Set timeout to stop typing indicator after 2 seconds of inactivity
typingTimeoutRef.current = setTimeout(() => {
if (isTypingRef.current && onTypingStop) {
isTypingRef.current = false;
onTypingStop();
}
}, 2000);
}, [onTypingStart, onTypingStop]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (isTypingRef.current && onTypingStop) {
onTypingStop();
}
};
}, [onTypingStop]);
const handleSend = () => {
if (message.trim() && onSend) {
// Stop typing indicator before sending
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (isTypingRef.current && onTypingStop) {
isTypingRef.current = false;
onTypingStop();
}
onSend(message.trim());
setMessage('');
}
@@ -24,13 +71,18 @@ export function MessageInput({ onSend }: MessageInputProps) {
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
handleTyping();
};
return (
<div className="border border-[#00293d]/10 rounded-[20px] bg-white p-4">
{/* Text input area */}
<div className="border border-[#00293d]/10 rounded-[20px] p-4 mb-3">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Write a Message........"
className="w-full min-h-[120px] resize-none font-serif font-normal text-[14px] leading-[19px] text-[#00293D] placeholder-[#00293D]/50 focus:outline-none"
@@ -79,7 +131,12 @@ export function MessageInput({ onSend }: MessageInputProps) {
<div className="flex items-center gap-2">
<button
onClick={handleSend}
className="px-4 py-1.5 bg-[#e58625] text-[#00293d] font-serif font-normal text-[14px] leading-[19px] rounded-[15px] hover:bg-[#d47720] transition-colors cursor-pointer"
disabled={!message.trim()}
className={`px-4 py-1.5 font-serif font-normal text-[14px] leading-[19px] rounded-[15px] transition-colors cursor-pointer ${
message.trim()
? 'bg-[#e58625] text-[#00293d] hover:bg-[#d47720]'
: 'bg-[#e58625]/50 text-[#00293d]/50 cursor-not-allowed'
}`}
>
Send
</button>

View File

@@ -1,9 +1,26 @@
'use client';
import { useState, useRef, useEffect } from 'react';
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 { useMessaging } from '@/hooks/useMessaging';
import { connectionRequestsService, type ConnectionRequest } from '@/services/connection-requests.service';
import { uploadService } from '@/services/upload.service';
import type { Conversation, Message } 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 = `
@@ -22,229 +39,251 @@ const customScrollbarStyles = `
}
`;
// Types
interface Conversation {
id: string;
// 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`;
}
// Connected agent with avatar URL
interface ConnectedAgent {
agentProfileId: string;
name: string;
avatar: string;
lastMessage: string;
date: string;
isOnline: boolean;
avatar: string | null;
avatarUrl: string | null;
headline: string | null;
city: string | null;
state: string | null;
}
interface Message {
id: string;
senderId: string;
text: string;
timestamp: string;
isOwn: boolean;
}
export function MessagingPage() {
const { data: session } = useSession();
const {
conversations,
currentConversation,
messages,
isConnected,
isLoading,
isLoadingMessages,
typingUsers,
selectConversation,
sendMessage,
startTyping,
stopTyping,
loadMoreMessages,
hasMoreMessages,
startConversation,
} = useMessaging();
interface SelectedUser {
name: string;
role: string;
lastSeen: string;
avatar: string;
isOnline: boolean;
pronouns?: string;
expertise?: string[];
}
interface MessagingPageProps {
conversations?: Conversation[];
messages?: Message[];
selectedUser?: SelectedUser;
}
// Mock data for conversations
const defaultConversations: Conversation[] = [
{
id: '1',
name: 'Pradeep Ram',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
date: 'Dec 4',
isOnline: true,
},
{
id: '2',
name: 'Pradeep',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
date: 'Dec 4',
isOnline: true,
},
{
id: '3',
name: 'Gokulraj',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
date: 'Dec 4',
isOnline: true,
},
{
id: '4',
name: 'Suriya s',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
date: 'Dec 4',
isOnline: true,
},
{
id: '5',
name: 'Sanjay',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
date: 'Dec 4',
isOnline: true,
},
{
id: '6',
name: 'Pradeep Ram',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
date: 'Dec 4',
isOnline: false,
},
{
id: '7',
name: 'Rahul Kumar',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'Thanks for your help with the property listing!',
date: 'Dec 3',
isOnline: true,
},
{
id: '8',
name: 'Anita Singh',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'Can we schedule a viewing for tomorrow?',
date: 'Dec 3',
isOnline: false,
},
{
id: '9',
name: 'Vikram Patel',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'The documents have been submitted.',
date: 'Dec 2',
isOnline: true,
},
{
id: '10',
name: 'Meera Sharma',
avatar: '/assets/icons/user-placeholder-icon.svg',
lastMessage: 'Looking forward to our meeting next week.',
date: 'Dec 2',
isOnline: false,
},
];
// Mock data for selected user
const defaultSelectedUser: SelectedUser = {
name: 'Pradeep Ram',
role: 'Advisor',
lastSeen: '21h Ago',
avatar: '/assets/icons/user-placeholder-icon.svg',
isOnline: true,
pronouns: 'He/Him',
expertise: ['Sales', 'Analytics', 'Inspection', 'Residential', 'Commercial'],
};
// Mock messages data
const defaultMessages: Message[] = [
{
id: '1',
senderId: '1',
text: 'Hi! I saw your profile and I think you might be a great fit for what I\'m looking for.',
timestamp: '10:30 AM',
isOwn: false,
},
{
id: '2',
senderId: 'me',
text: 'Hello! Thank you for reaching out. I\'d be happy to help. What kind of property are you looking for?',
timestamp: '10:32 AM',
isOwn: true,
},
{
id: '3',
senderId: '1',
text: 'I\'m looking for a residential property in the downtown area. Preferably a 3-bedroom apartment with modern amenities.',
timestamp: '10:35 AM',
isOwn: false,
},
{
id: '4',
senderId: 'me',
text: 'That sounds great! I have several listings that might interest you. Do you have a specific budget range in mind?',
timestamp: '10:38 AM',
isOwn: true,
},
{
id: '5',
senderId: '1',
text: 'My budget is around $500,000 to $700,000. I\'m also interested in properties with good investment potential.',
timestamp: '10:40 AM',
isOwn: false,
},
{
id: '6',
senderId: 'me',
text: 'Perfect! I have a few properties in that range. Would you like to schedule a viewing this weekend?',
timestamp: '10:42 AM',
isOwn: true,
},
{
id: '7',
senderId: '1',
text: 'Yes, that would be great! Saturday afternoon works best for me.',
timestamp: '10:45 AM',
isOwn: false,
},
{
id: '8',
senderId: 'me',
text: 'Saturday at 2 PM works for me. I\'ll send you the addresses and details of the properties we\'ll be visiting.',
timestamp: '10:48 AM',
isOwn: true,
},
{
id: '9',
senderId: '1',
text: 'Sounds perfect! Looking forward to it. Thank you for your quick response.',
timestamp: '10:50 AM',
isOwn: false,
},
{
id: '10',
senderId: 'me',
text: 'You\'re welcome! See you on Saturday. Feel free to reach out if you have any questions before then.',
timestamp: '10:52 AM',
isOwn: true,
},
];
export function MessagingPage({
conversations = defaultConversations,
messages = defaultMessages,
selectedUser = defaultSelectedUser,
}: MessagingPageProps) {
const [selectedConversation, setSelectedConversation] = useState<string | null>('1');
const [searchQuery, setSearchQuery] = useState('');
const [showScrollButton, setShowScrollButton] = useState(false);
const [connectedAgents, setConnectedAgents] = useState<ConnectedAgent[]>([]);
const [isLoadingAgents, setIsLoadingAgents] = useState(false);
const [isStartingConversation, setIsStartingConversation] = useState<string | null>(null);
// Store converted avatar URLs by conversation ID
const [conversationAvatarUrls, setConversationAvatarUrls] = useState<Record<string, string>>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const handleSendMessage = (message: string) => {
console.log('Sending message:', message);
// In production, this would send the message to the API
};
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<string, string> = {};
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]);
// 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 typing events
const handleTypingStart = useCallback(() => {
startTyping();
}, [startTyping]);
const handleTypingStop = useCallback(() => {
stopTyping();
}, [stopTyping]);
// 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();
}
}
};
@@ -257,15 +296,47 @@ export function MessagingPage({
}
};
// Scroll to bottom when new message arrives or conversation changes
useEffect(() => {
// Scroll to bottom on initial load
scrollToBottom();
}, [selectedConversation]);
}, [currentConversation?.id]);
// Scroll to bottom when new messages are added (not when loading more)
useEffect(() => {
if (messages.length > 0) {
const lastMessage = messages[messages.length - 1];
const isVeryRecent = new Date().getTime() - new Date(lastMessage.createdAt).getTime() < 2000;
if (isVeryRecent) {
scrollToBottom();
}
}
}, [messages]);
// Filter conversations by search
const filteredConversations = conversations.filter((conv) =>
conv.name.toLowerCase().includes(searchQuery.toLowerCase())
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 (
<>
<style>{customScrollbarStyles}</style>
@@ -276,6 +347,14 @@ export function MessagingPage({
Messaging
</h1>
{/* Connection status indicator */}
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-400'}`}
title={isConnected ? 'Connected' : 'Disconnected'}
/>
</div>
{/* Search bar */}
<div className="flex-1 max-w-[600px]">
<div className="flex items-center gap-2 border border-[#00293d]/10 rounded-[15px] px-4 py-2">
@@ -320,97 +399,238 @@ export function MessagingPage({
<div className="flex h-[700px]">
{/* Left sidebar - Conversation list */}
<div className="w-[380px] border-r border-[#00293d]/10 flex-shrink-0 overflow-hidden flex flex-col">
<div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
{filteredConversations.map((conversation) => (
<div
key={conversation.id}
onClick={() => setSelectedConversation(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 */}
{selectedConversation === conversation.id && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[4px] h-[70px] bg-[#E58625] rounded-r-[4px]" />
)}
{/* Avatar with online indicator */}
<div className="relative flex-shrink-0">
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20">
<Image
src={conversation.avatar}
alt={conversation.name}
width={70}
height={70}
className="w-full h-full object-cover rounded-full"
/>
</div>
{conversation.isOnline && (
<div className="absolute bottom-1 right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-white" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="font-fractul font-medium text-[14px] leading-[17px] text-[#00293D] mb-1">
{conversation.name}
</p>
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] line-clamp-2">
{conversation.lastMessage}
</p>
</div>
{/* Date */}
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] flex-shrink-0">
{conversation.date}
{isLoading || isLoadingAgents ? (
<div className="flex-1 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#00293d]" />
</div>
) : filteredConversations.length === 0 ? (
/* Show connected agents when no conversations */
<div className="flex-1 flex flex-col">
{/* Header for connected agents */}
<div className="p-4 border-b border-[#00293d]/10">
<p className="font-serif font-normal text-[14px] text-[#00293D]/50 text-center">
{searchQuery ? 'No conversations found' : 'No conversations yet. Start a conversation with an agent!'}
</p>
</div>
))}
</div>
{/* List of connected agents */}
{connectedAgents.length > 0 ? (
<div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
<p className="px-2 py-1 font-fractul font-medium text-[12px] text-[#00293D]/70 uppercase tracking-wide">
Your Connected Agents
</p>
{connectedAgents
.filter((agent) =>
agent.name.toLowerCase().includes(searchQuery.toLowerCase())
)
.map((agent) => (
<button
key={agent.agentProfileId}
onClick={() => handleStartConversation(agent.agentProfileId)}
disabled={isStartingConversation === agent.agentProfileId}
className="w-full flex items-center gap-3 p-4 rounded-[15px] cursor-pointer transition-colors hover:bg-[#00293d]/5 disabled:opacity-50"
style={{ border: '1px solid rgba(0, 41, 61, 0.1)' }}
>
{/* Avatar */}
<div className="relative flex-shrink-0">
<div className="w-[50px] h-[50px] rounded-full overflow-hidden border border-[#00293D]/20">
<Image
src={getValidAvatarUrl(agent.avatarUrl)}
alt={agent.name}
width={50}
height={50}
className="object-cover rounded-full"
style={{ width: '100%', height: '100%' }}
/>
</div>
</div>
{/* Agent Info */}
<div className="flex-1 min-w-0 text-left">
<p className="font-fractul font-medium text-[14px] leading-[17px] text-[#00293D]">
{agent.name}
</p>
{agent.headline && (
<p className="font-serif font-normal text-[12px] leading-[16px] text-[#00293D]/70 truncate">
{agent.headline}
</p>
)}
{(agent.city || agent.state) && (
<p className="font-serif font-normal text-[12px] leading-[16px] text-[#00293D]/50">
{[agent.city, agent.state].filter(Boolean).join(', ')}
</p>
)}
</div>
{/* Start conversation indicator */}
<div className="flex-shrink-0">
{isStartingConversation === agent.agentProfileId ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[#e58625]" />
) : (
<div className="w-8 h-8 rounded-full bg-[#e58625]/10 flex items-center justify-center">
<Image
src="/assets/icons/message-icon.svg"
alt="Start chat"
width={16}
height={16}
className="opacity-70"
/>
</div>
)}
</div>
</button>
))}
</div>
) : (
<div className="flex-1 flex items-center justify-center text-[#00293D]/50 font-serif p-4 text-center">
<p>No connected agents yet. Connect with agents to start messaging!</p>
</div>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
{filteredConversations.map((conversation) => (
<div
key={conversation.id}
onClick={() => 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 && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[4px] h-[70px] bg-[#E58625] rounded-r-[4px]" />
)}
{/* Avatar with online indicator */}
<div className="relative flex-shrink-0">
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20">
<Image
src={getValidAvatarUrl(conversationAvatarUrls[conversation.id])}
alt={conversation.otherParty?.name || 'User'}
width={70}
height={70}
className="object-cover rounded-full"
style={{ width: '100%', height: '100%' }}
/>
</div>
{conversation.otherParty?.isOnline && (
<div className="absolute bottom-1 right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-white" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-fractul font-medium text-[14px] leading-[17px] text-[#00293D]">
{conversation.otherParty?.name || 'Unknown'}
</p>
{conversation.unreadCount > 0 && (
<span className="bg-[#e58625] text-white text-xs font-bold px-2 py-0.5 rounded-full min-w-[20px] text-center">
{conversation.unreadCount}
</span>
)}
</div>
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] line-clamp-2">
{conversation.lastMessageText || 'No messages yet'}
</p>
</div>
{/* Date */}
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] flex-shrink-0">
{formatConversationDate(conversation.lastMessageAt)}
</p>
</div>
))}
</div>
)}
</div>
{/* Right panel - Chat area */}
<div className="flex-1 flex flex-col p-4">
{selectedConversation ? (
{currentConversation && selectedUserInfo ? (
<>
{/* Chat header */}
<ChatHeader {...selectedUser} />
<ChatHeader
{...selectedUserInfo}
isTyping={typingUserNames.length > 0}
typingText={typingUserNames.length > 0 ? `${typingUserNames.join(', ')} is typing...` : undefined}
/>
{/* Messages area wrapper */}
<div className="flex-1 relative">
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className="absolute inset-0 overflow-y-auto py-4 custom-scrollbar"
>
<div className="space-y-4 px-2">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.isOwn ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-[15px] px-4 py-3 ${
message.isOwn
? 'bg-[#e58625] text-[#00293D]'
: 'bg-[#00293d]/10 text-[#00293D]'
}`}
>
<p className="font-serif font-normal text-[14px] leading-[19px]">
{message.text}
</p>
<p
className={`font-serif font-normal text-[12px] leading-[16px] mt-1 ${
message.isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'
}`}
>
{message.timestamp}
</p>
</div>
</div>
))}
{isLoadingMessages && messages.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#00293d]" />
</div>
</div>
) : (
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className="absolute inset-0 overflow-y-auto py-4 custom-scrollbar"
>
{/* Load more indicator */}
{isLoadingMessages && messages.length > 0 && (
<div className="flex justify-center py-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[#00293d]" />
</div>
)}
{/* Scroll to bottom button - only shows when scrolled up */}
<div className="space-y-4 px-2">
{messages.map((message) => {
const isOwn = message.senderId === currentUserId;
return (
<div
key={message.id}
className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-[15px] px-4 py-3 ${
isOwn
? 'bg-[#e58625] text-[#00293D]'
: 'bg-[#00293d]/10 text-[#00293D]'
}`}
>
<p className="font-serif font-normal text-[14px] leading-[19px]">
{message.content}
</p>
<div className={`flex items-center gap-2 mt-1 ${isOwn ? 'justify-end' : 'justify-start'}`}>
<p
className={`font-serif font-normal text-[12px] leading-[16px] ${
isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'
}`}
>
{formatMessageTime(message.createdAt)}
</p>
{isOwn && (
<span className={`text-[10px] ${message.status === 'READ' ? 'text-blue-500' : 'text-[#00293D]/50'}`}>
{message.status === 'READ' ? '✓✓' : message.status === 'DELIVERED' ? '✓✓' : '✓'}
</span>
)}
</div>
</div>
</div>
);
})}
{/* Typing indicator in messages */}
{typingUserNames.length > 0 && (
<div className="flex justify-start">
<div className="bg-[#00293d]/10 text-[#00293D] rounded-[15px] px-4 py-3">
<div className="flex items-center gap-1">
<span className="w-2 h-2 bg-[#00293D]/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-[#00293D]/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-[#00293D]/50 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
)}
{/* Scroll to bottom button */}
{showScrollButton && (
<button
onClick={scrollToBottom}
@@ -427,11 +647,22 @@ export function MessagingPage({
</div>
{/* Message input */}
<MessageInput onSend={handleSendMessage} />
<MessageInput
onSend={handleSendMessage}
onTypingStart={handleTypingStart}
onTypingStop={handleTypingStop}
/>
</>
) : (
<div className="flex items-center justify-center h-full text-[#00293D]/50 font-serif">
Select a conversation to start messaging
<div className="flex items-center justify-center h-full text-[#00293D]/50 font-serif flex-col gap-4">
<Image
src="/assets/icons/message-icon.svg"
alt="Messages"
width={64}
height={64}
className="opacity-30"
/>
<p>Select a conversation to start messaging</p>
</div>
)}
</div>