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

88
package-lock.json generated
View File

@@ -13,7 +13,8 @@
"next-auth": "^5.0.0-beta.30",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-select": "^5.10.2"
"react-select": "^5.10.2",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1414,6 +1415,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -3076,6 +3083,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
@@ -6304,6 +6333,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -7002,6 +7059,35 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "NODE_OPTIONS='--max-old-space-size=4096' next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
@@ -14,7 +14,8 @@
"next-auth": "^5.0.0-beta.30",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-select": "^5.10.2"
"react-select": "^5.10.2",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -165,7 +165,8 @@ export function CommonHeader() {
alt="Profile"
width={35}
height={35}
className="w-full h-full object-cover"
className="object-cover"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
@@ -201,7 +202,8 @@ export function CommonHeader() {
alt="Profile"
width={42}
height={42}
className="w-full h-full object-cover"
className="object-cover"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>

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">
{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>
{/* 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={() => setSelectedConversation(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 */}
{selectedConversation === conversation.id && (
{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={conversation.avatar}
alt={conversation.name}
src={getValidAvatarUrl(conversationAvatarUrls[conversation.id])}
alt={conversation.otherParty?.name || 'User'}
width={70}
height={70}
className="w-full h-full object-cover rounded-full"
className="object-cover rounded-full"
style={{ width: '100%', height: '100%' }}
/>
</div>
{conversation.isOnline && (
{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">
<p className="font-fractul font-medium text-[14px] leading-[17px] text-[#00293D] mb-1">
{conversation.name}
<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.lastMessage}
{conversation.lastMessageText || 'No messages yet'}
</p>
</div>
{/* Date */}
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] flex-shrink-0">
{conversation.date}
{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">
{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
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>
)}
<div className="space-y-4 px-2">
{messages.map((message) => (
{messages.map((message) => {
const isOwn = message.senderId === currentUserId;
return (
<div
key={message.id}
className={`flex ${message.isOwn ? 'justify-end' : 'justify-start'}`}
className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-[15px] px-4 py-3 ${
message.isOwn
isOwn
? 'bg-[#e58625] text-[#00293D]'
: 'bg-[#00293d]/10 text-[#00293D]'
}`}
>
<p className="font-serif font-normal text-[14px] leading-[19px]">
{message.text}
{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] mt-1 ${
message.isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'
className={`font-serif font-normal text-[12px] leading-[16px] ${
isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'
}`}
>
{message.timestamp}
{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>
</div>
);
})}
{/* Scroll to bottom button - only shows when scrolled up */}
{/* 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>

462
src/hooks/useMessaging.ts Normal file
View File

@@ -0,0 +1,462 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { socketService, messagesService } from '@/services';
import type {
Conversation,
Message,
CreateMessageDto,
TypingIndicator,
UserStatusChange,
MessagesReadEvent,
MessageType,
MessageStatus,
} from '@/types/messaging';
interface UseMessagingOptions {
autoConnect?: boolean;
}
interface UseMessagingReturn {
// State
conversations: Conversation[];
currentConversation: Conversation | null;
messages: Message[];
isConnected: boolean;
isLoading: boolean;
isLoadingMessages: boolean;
typingUsers: Set<string>;
unreadCount: number;
// Actions
connect: () => Promise<void>;
disconnect: () => void;
loadConversations: () => Promise<void>;
selectConversation: (conversationId: string) => Promise<void>;
startConversation: (agentProfileId: string) => Promise<Conversation>;
sendMessage: (content: string, messageType?: MessageType) => Promise<void>;
loadMoreMessages: () => Promise<void>;
startTyping: () => void;
stopTyping: () => void;
markAsRead: () => Promise<void>;
updateUserStatus: (userId: string, isOnline: boolean) => void;
hasMoreMessages: boolean;
}
export function useMessaging(options: UseMessagingOptions = {}): UseMessagingReturn {
const { autoConnect = true } = options;
const { data: session } = useSession();
// State
const [conversations, setConversations] = useState<Conversation[]>([]);
const [currentConversation, setCurrentConversation] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
const [unreadCount, setUnreadCount] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [hasMoreMessages, setHasMoreMessages] = useState(true);
// Refs
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentConversationIdRef = useRef<string | null>(null);
const isInitializedRef = useRef(false);
// Get access token from session
const accessToken = session?.user?.accessToken;
// Connect to WebSocket
const connect = useCallback(async () => {
if (!accessToken) {
console.warn('No access token available for socket connection');
return;
}
try {
await socketService.connect(accessToken);
} catch (error) {
console.error('Failed to connect to socket:', error);
}
}, [accessToken]);
// Disconnect from WebSocket
const disconnect = useCallback(() => {
socketService.disconnect();
}, []);
// Load conversations
const loadConversations = useCallback(async () => {
if (!accessToken) return;
setIsLoading(true);
try {
const data = await messagesService.getConversations();
setConversations(data);
} catch (error) {
console.error('Failed to load conversations:', error);
} finally {
setIsLoading(false);
}
}, [accessToken]);
// Select a conversation and load its messages
const selectConversation = useCallback(
async (conversationId: string) => {
if (currentConversationIdRef.current === conversationId) return;
// Leave previous conversation room
if (currentConversationIdRef.current) {
socketService.leaveConversation(currentConversationIdRef.current);
}
currentConversationIdRef.current = conversationId;
setCurrentPage(1);
setHasMoreMessages(true);
setMessages([]);
setTypingUsers(new Set());
setIsLoadingMessages(true);
try {
// Get conversation details
const conversation = await messagesService.getConversation(conversationId);
setCurrentConversation(conversation);
// Join WebSocket room
if (isConnected) {
await socketService.joinConversation(conversationId);
}
// Load messages
const { messages: loadedMessages, pagination } =
await messagesService.getMessages(conversationId, 1, 50);
setMessages(loadedMessages);
setHasMoreMessages(pagination.page < pagination.pages);
// Mark as read
await messagesService.markAsRead(conversationId);
// Update unread count in conversations list
setConversations((prev) =>
prev.map((c) => (c.id === conversationId ? { ...c, unreadCount: 0 } : c))
);
} catch (error) {
console.error('Failed to load conversation:', error);
} finally {
setIsLoadingMessages(false);
}
},
[isConnected]
);
// Start a new conversation with an agent
const startConversation = useCallback(
async (agentProfileId: string): Promise<Conversation> => {
const conversation = await messagesService.startConversation(agentProfileId);
// Add to conversations list if not exists
setConversations((prev) => {
const exists = prev.some((c) => c.id === conversation.id);
if (exists) return prev;
return [conversation, ...prev];
});
return conversation;
},
[]
);
// Send a message
const sendMessage = useCallback(
async (content: string, messageType: MessageType = 'TEXT' as MessageType) => {
if (!currentConversationIdRef.current) return;
const dto: CreateMessageDto = {
content,
messageType,
};
// Stop typing indicator
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
socketService.stopTyping(currentConversationIdRef.current);
// Try WebSocket first, fallback to REST
if (isConnected) {
const result = await socketService.sendMessage(
currentConversationIdRef.current,
dto
);
if (result.success && result.message) {
// Immediately update UI with the sent message (optimistic update)
setMessages((prev) => {
// Avoid duplicates (in case broadcast also arrives)
if (prev.some((m) => m.id === result.message!.id)) return prev;
return [...prev, result.message!];
});
} else if (!result.success) {
// Fallback to REST
const message = await messagesService.sendMessage(
currentConversationIdRef.current,
dto
);
setMessages((prev) => [...prev, message]);
}
} else {
// REST fallback
const message = await messagesService.sendMessage(
currentConversationIdRef.current,
dto
);
setMessages((prev) => [...prev, message]);
}
// Update conversation in list
const conversationId = currentConversationIdRef.current;
setConversations((prev) =>
prev.map((c) =>
c.id === conversationId
? {
...c,
lastMessageAt: new Date().toISOString(),
lastMessageText:
content.length > 255 ? content.substring(0, 252) + '...' : content,
}
: c
)
);
},
[isConnected]
);
// Load more messages (pagination) with debouncing for smooth scrolling
const loadMoreMessages = useCallback(async () => {
if (!currentConversationIdRef.current || !hasMoreMessages || isLoadingMessages)
return;
setIsLoadingMessages(true);
try {
const nextPage = currentPage + 1;
const { messages: moreMessages, pagination } = await messagesService.getMessages(
currentConversationIdRef.current,
nextPage,
50
);
// Prepend older messages smoothly
setMessages((prev) => [...moreMessages, ...prev]);
setCurrentPage(nextPage);
setHasMoreMessages(pagination.page < pagination.pages);
} catch (error) {
console.error('Failed to load more messages:', error);
} finally {
setIsLoadingMessages(false);
}
}, [currentPage, hasMoreMessages, isLoadingMessages]);
// Start typing indicator
const startTyping = useCallback(() => {
if (!currentConversationIdRef.current) return;
socketService.startTyping(currentConversationIdRef.current);
// Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Auto-stop typing after 3 seconds
typingTimeoutRef.current = setTimeout(() => {
if (currentConversationIdRef.current) {
socketService.stopTyping(currentConversationIdRef.current);
}
}, 3000);
}, []);
// Stop typing indicator
const stopTyping = useCallback(() => {
if (!currentConversationIdRef.current) return;
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
socketService.stopTyping(currentConversationIdRef.current);
}, []);
// Mark messages as read
const markAsRead = useCallback(async () => {
if (!currentConversationIdRef.current) return;
if (isConnected) {
await socketService.markAsRead(currentConversationIdRef.current);
} else {
await messagesService.markAsRead(currentConversationIdRef.current);
}
}, [isConnected]);
// Update user online status (called from status change events)
const updateUserStatus = useCallback((userId: string, isOnline: boolean) => {
setConversations((prev) =>
prev.map((c) => {
if (c.otherParty.userId === userId || c.otherParty.id === userId) {
return {
...c,
otherParty: { ...c.otherParty, isOnline },
};
}
return c;
})
);
// Update current conversation if applicable
setCurrentConversation((prev) => {
if (!prev) return prev;
if (
prev.otherParty.userId === userId ||
prev.otherParty.id === userId
) {
return {
...prev,
otherParty: { ...prev.otherParty, isOnline },
};
}
return prev;
});
}, []);
// Set up socket event listeners
useEffect(() => {
const unsubscribeConnection = socketService.onConnectionChange((connected) => {
setIsConnected(connected);
});
const unsubscribeMessage = socketService.onNewMessage((message) => {
// Only add if it's for the current conversation
if (message.conversationId === currentConversationIdRef.current) {
setMessages((prev) => {
// Avoid duplicates
if (prev.some((m) => m.id === message.id)) return prev;
return [...prev, message];
});
}
// Update conversation list
setConversations((prev) =>
prev.map((c) => {
if (c.id === message.conversationId) {
const isCurrentConversation =
c.id === currentConversationIdRef.current;
return {
...c,
lastMessageAt: message.createdAt,
lastMessageText: message.content.substring(0, 255),
unreadCount: isCurrentConversation ? 0 : c.unreadCount + 1,
};
}
return c;
})
);
});
const unsubscribeTypingStart = socketService.onTypingStart(
(data: TypingIndicator) => {
if (data.conversationId === currentConversationIdRef.current) {
setTypingUsers((prev) => new Set(prev).add(data.userId));
}
}
);
const unsubscribeTypingStop = socketService.onTypingStop(
(data: TypingIndicator) => {
if (data.conversationId === currentConversationIdRef.current) {
setTypingUsers((prev) => {
const next = new Set(prev);
next.delete(data.userId);
return next;
});
}
}
);
const unsubscribeStatus = socketService.onUserStatusChange(
(data: UserStatusChange) => {
updateUserStatus(data.userId, data.isOnline);
}
);
const unsubscribeRead = socketService.onMessagesRead(
(data: MessagesReadEvent) => {
if (data.conversationId === currentConversationIdRef.current) {
// Update message statuses
setMessages((prev) =>
prev.map((m) => ({
...m,
status: 'READ' as MessageStatus,
readAt: data.readAt,
}))
);
}
}
);
return () => {
unsubscribeConnection();
unsubscribeMessage();
unsubscribeTypingStart();
unsubscribeTypingStop();
unsubscribeStatus();
unsubscribeRead();
};
}, [updateUserStatus]);
// Auto-connect on mount
useEffect(() => {
if (autoConnect && accessToken && !isInitializedRef.current) {
isInitializedRef.current = true;
connect();
loadConversations();
// Get initial unread count
messagesService.getUnreadCount().then(setUnreadCount).catch(console.error);
}
// Don't disconnect on cleanup - socket is singleton and should stay connected
// Disconnect only happens when user logs out (handled elsewhere)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoConnect, accessToken]);
// Re-join conversation room when reconnecting
useEffect(() => {
if (isConnected && currentConversationIdRef.current) {
socketService.joinConversation(currentConversationIdRef.current);
}
}, [isConnected]);
return {
conversations,
currentConversation,
messages,
isConnected,
isLoading,
isLoadingMessages,
typingUsers,
unreadCount,
connect,
disconnect,
loadConversations,
selectConversation,
startConversation,
sendMessage,
loadMoreMessages,
startTyping,
stopTyping,
markAsRead,
updateUserStatus,
hasMoreMessages,
};
}

View File

@@ -47,3 +47,7 @@ export type {
TwoFactorLoginResponse,
TwoFactorRequiredResponse,
} from './two-factor.service';
// Messaging Services
export { socketService } from './socket.service';
export { messagesService } from './messages.service';

View File

@@ -0,0 +1,86 @@
import api from './api';
import type {
Conversation,
Message,
MessagesResponse,
CreateMessageDto,
} from '@/types/messaging';
interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
class MessagesService {
/**
* Get all conversations for the current user
*/
async getConversations(): Promise<Conversation[]> {
const response = await api.get<ApiResponse<Conversation[]>>('/messages/conversations');
return response.data.data;
}
/**
* Start or get existing conversation with an agent
*/
async startConversation(agentProfileId: string): Promise<Conversation> {
const response = await api.post<ApiResponse<Conversation>>('/messages/conversations', {
agentProfileId,
});
return response.data.data;
}
/**
* Get a specific conversation
*/
async getConversation(conversationId: string): Promise<Conversation> {
const response = await api.get<ApiResponse<Conversation>>(
`/messages/conversations/${conversationId}`
);
return response.data.data;
}
/**
* Get messages for a conversation with pagination
*/
async getMessages(
conversationId: string,
page: number = 1,
limit: number = 50
): Promise<MessagesResponse> {
const response = await api.get<ApiResponse<MessagesResponse>>(
`/messages/conversations/${conversationId}/messages`,
{ params: { page, limit } }
);
return response.data.data;
}
/**
* Send a message via REST API (fallback for WebSocket)
*/
async sendMessage(conversationId: string, dto: CreateMessageDto): Promise<Message> {
const response = await api.post<ApiResponse<Message>>(
`/messages/conversations/${conversationId}/messages`,
dto
);
return response.data.data;
}
/**
* Mark all messages in a conversation as read
*/
async markAsRead(conversationId: string): Promise<void> {
await api.patch(`/messages/conversations/${conversationId}/read`);
}
/**
* Get total unread message count
*/
async getUnreadCount(): Promise<number> {
const response = await api.get<ApiResponse<{ unreadCount: number }>>('/messages/unread-count');
return response.data.data.unreadCount;
}
}
export const messagesService = new MessagesService();

View File

@@ -0,0 +1,234 @@
import { io, Socket } from 'socket.io-client';
import type {
Message,
TypingIndicator,
UserStatusChange,
MessagesReadEvent,
CreateMessageDto,
} from '@/types/messaging';
type MessageHandler = (message: Message) => void;
type TypingHandler = (data: TypingIndicator) => void;
type StatusHandler = (data: UserStatusChange) => void;
type ReadHandler = (data: MessagesReadEvent) => void;
class SocketService {
private socket: Socket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private messageHandlers: Set<MessageHandler> = new Set();
private typingStartHandlers: Set<TypingHandler> = new Set();
private typingStopHandlers: Set<TypingHandler> = new Set();
private statusHandlers: Set<StatusHandler> = new Set();
private readHandlers: Set<ReadHandler> = new Set();
private connectionHandlers: Set<(connected: boolean) => void> = new Set();
private isConnecting = false;
connect(token: string): Promise<void> {
return new Promise((resolve, reject) => {
// Already connected
if (this.socket?.connected) {
resolve();
return;
}
// Already connecting
if (this.isConnecting) {
// Wait for existing connection attempt
const checkConnection = setInterval(() => {
if (this.socket?.connected) {
clearInterval(checkConnection);
resolve();
}
}, 100);
setTimeout(() => {
clearInterval(checkConnection);
if (!this.socket?.connected) {
reject(new Error('Connection timeout'));
}
}, 10000);
return;
}
this.isConnecting = true;
// Clean up existing socket if any (but not if it's connecting)
if (this.socket && !this.socket.connected) {
this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
}
// Extract base URL without /api/v1 path for Socket.io connection
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const baseUrl = apiUrl.replace(/\/api\/v1\/?$/, '');
this.socket = io(baseUrl, {
auth: { token },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: this.maxReconnectAttempts,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
this.socket.on('connect', () => {
console.log('Socket connected:', this.socket?.id);
this.isConnecting = false;
this.reconnectAttempts = 0;
this.notifyConnectionHandlers(true);
resolve();
});
this.socket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason);
this.isConnecting = false;
this.notifyConnectionHandlers(false);
});
this.socket.on('connect_error', (error) => {
console.error('Socket connection error:', error.message);
this.isConnecting = false;
this.reconnectAttempts++;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
reject(new Error('Max reconnection attempts reached'));
}
});
// Set up event listeners
this.socket.on('new_message', (message: Message) => {
this.messageHandlers.forEach((handler) => handler(message));
});
this.socket.on('typing_start', (data: TypingIndicator) => {
this.typingStartHandlers.forEach((handler) => handler(data));
});
this.socket.on('typing_stop', (data: TypingIndicator) => {
this.typingStopHandlers.forEach((handler) => handler(data));
});
this.socket.on('user_status_change', (data: UserStatusChange) => {
this.statusHandlers.forEach((handler) => handler(data));
});
this.socket.on('messages_read', (data: MessagesReadEvent) => {
this.readHandlers.forEach((handler) => handler(data));
});
});
}
disconnect(): void {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
isConnected(): boolean {
return this.socket?.connected || false;
}
// Room management
joinConversation(conversationId: string): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
if (!this.socket) {
resolve({ success: false, error: 'Not connected' });
return;
}
this.socket.emit('join_conversation', { conversationId }, (response: { success: boolean; error?: string }) => {
resolve(response);
});
});
}
leaveConversation(conversationId: string): void {
if (this.socket) {
this.socket.emit('leave_conversation', { conversationId });
}
}
// Messaging
sendMessage(
conversationId: string,
message: CreateMessageDto
): Promise<{ success: boolean; message?: Message; error?: string }> {
return new Promise((resolve) => {
if (!this.socket) {
resolve({ success: false, error: 'Not connected' });
return;
}
this.socket.emit(
'send_message',
{ conversationId, message },
(response: { success: boolean; message?: Message; error?: string }) => {
resolve(response);
}
);
});
}
// Typing indicators
startTyping(conversationId: string): void {
if (this.socket) {
this.socket.emit('typing_start', { conversationId });
}
}
stopTyping(conversationId: string): void {
if (this.socket) {
this.socket.emit('typing_stop', { conversationId });
}
}
// Mark messages as read
markAsRead(conversationId: string): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
if (!this.socket) {
resolve({ success: false, error: 'Not connected' });
return;
}
this.socket.emit('mark_read', { conversationId }, (response: { success: boolean; error?: string }) => {
resolve(response);
});
});
}
// Event handlers registration
onNewMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler);
return () => this.messageHandlers.delete(handler);
}
onTypingStart(handler: TypingHandler): () => void {
this.typingStartHandlers.add(handler);
return () => this.typingStartHandlers.delete(handler);
}
onTypingStop(handler: TypingHandler): () => void {
this.typingStopHandlers.add(handler);
return () => this.typingStopHandlers.delete(handler);
}
onUserStatusChange(handler: StatusHandler): () => void {
this.statusHandlers.add(handler);
return () => this.statusHandlers.delete(handler);
}
onMessagesRead(handler: ReadHandler): () => void {
this.readHandlers.add(handler);
return () => this.readHandlers.delete(handler);
}
onConnectionChange(handler: (connected: boolean) => void): () => void {
this.connectionHandlers.add(handler);
return () => this.connectionHandlers.delete(handler);
}
private notifyConnectionHandlers(connected: boolean): void {
this.connectionHandlers.forEach((handler) => handler(connected));
}
}
// Singleton instance
export const socketService = new SocketService();

108
src/types/messaging.ts Normal file
View File

@@ -0,0 +1,108 @@
export enum MessageType {
TEXT = 'TEXT',
FILE = 'FILE',
IMAGE = 'IMAGE',
SYSTEM = 'SYSTEM',
}
export enum MessageStatus {
SENT = 'SENT',
DELIVERED = 'DELIVERED',
READ = 'READ',
}
export interface OtherParty {
id: string;
userId?: string;
name: string;
avatar?: string | null;
headline?: string;
isOnline: boolean;
lastSeenAt?: string | null;
}
export interface Conversation {
id: string;
userId: string;
agentProfileId: string;
lastMessageAt: string | null;
lastMessageText: string | null;
userUnreadCount: number;
agentUnreadCount: number;
unreadCount: number;
createdAt: string;
updatedAt: string;
otherParty: OtherParty;
messages?: Message[];
}
export interface MessageSender {
id: string;
role: string;
userProfile?: {
firstName: string | null;
lastName: string | null;
avatar: string | null;
};
agentProfile?: {
firstName: string | null;
lastName: string | null;
avatar: string | null;
};
}
export interface Message {
id: string;
conversationId: string;
senderId: string;
content: string;
messageType: MessageType;
fileUrl?: string | null;
fileName?: string | null;
fileSize?: number | null;
mimeType?: string | null;
status: MessageStatus;
deliveredAt?: string | null;
readAt?: string | null;
createdAt: string;
updatedAt: string;
sender: MessageSender;
}
export interface CreateMessageDto {
content: string;
messageType?: MessageType;
fileUrl?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
}
export interface TypingIndicator {
userId: string;
conversationId: string;
}
export interface UserStatusChange {
userId: string;
isOnline: boolean;
lastSeenAt?: string | null;
}
export interface MessagesReadEvent {
conversationId: string;
readBy: string;
readAt: string;
}
export interface PaginationInfo {
page: number;
limit: number;
total: number;
pages: number;
}
export interface MessagesResponse {
messages: Message[];
pagination: PaginationInfo;
}