feat: Implement real-time messaging functionality with dedicated services, types, a custom hook, and UI integration.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user