feat: Implement real-time messaging functionality with dedicated services, types, a custom hook, and UI integration.
This commit is contained in:
88
package-lock.json
generated
88
package-lock.json
generated
@@ -13,7 +13,8 @@
|
|||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -1414,6 +1415,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -3076,6 +3083,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.4",
|
"version": "5.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||||
@@ -6304,6 +6333,34 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.5.7",
|
"version": "0.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||||
@@ -7002,6 +7059,35 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "NODE_OPTIONS='--max-old-space-size=4096' next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ export function CommonHeader() {
|
|||||||
alt="Profile"
|
alt="Profile"
|
||||||
width={35}
|
width={35}
|
||||||
height={35}
|
height={35}
|
||||||
className="w-full h-full object-cover"
|
className="object-cover"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +202,8 @@ export function CommonHeader() {
|
|||||||
alt="Profile"
|
alt="Profile"
|
||||||
width={42}
|
width={42}
|
||||||
height={42}
|
height={42}
|
||||||
className="w-full h-full object-cover"
|
className="object-cover"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface ChatHeaderProps {
|
|||||||
isOnline?: boolean;
|
isOnline?: boolean;
|
||||||
pronouns?: string;
|
pronouns?: string;
|
||||||
expertise?: string[];
|
expertise?: string[];
|
||||||
|
isTyping?: boolean;
|
||||||
|
typingText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
@@ -20,6 +22,8 @@ export function ChatHeader({
|
|||||||
isOnline = false,
|
isOnline = false,
|
||||||
pronouns,
|
pronouns,
|
||||||
expertise = [],
|
expertise = [],
|
||||||
|
isTyping = false,
|
||||||
|
typingText,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-[#00293d]/10 pb-4">
|
<div className="border-b border-[#00293d]/10 pb-4">
|
||||||
@@ -34,7 +38,11 @@ export function ChatHeader({
|
|||||||
{role}
|
{role}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D]">
|
<span className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D]">
|
||||||
{lastSeen}
|
{isTyping ? (
|
||||||
|
<span className="text-[#e58625] italic">{typingText}</span>
|
||||||
|
) : (
|
||||||
|
lastSeen
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -67,7 +75,8 @@ export function ChatHeader({
|
|||||||
alt={name}
|
alt={name}
|
||||||
width={50}
|
width={50}
|
||||||
height={50}
|
height={50}
|
||||||
className="w-full h-full object-cover rounded-full"
|
className="object-cover rounded-full"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isOnline && (
|
{isOnline && (
|
||||||
|
|||||||
@@ -1,17 +1,64 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
interface MessageInputProps {
|
interface MessageInputProps {
|
||||||
onSend?: (message: string) => void;
|
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 [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 = () => {
|
const handleSend = () => {
|
||||||
if (message.trim() && onSend) {
|
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());
|
onSend(message.trim());
|
||||||
setMessage('');
|
setMessage('');
|
||||||
}
|
}
|
||||||
@@ -24,13 +71,18 @@ export function MessageInput({ onSend }: MessageInputProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setMessage(e.target.value);
|
||||||
|
handleTyping();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-[#00293d]/10 rounded-[20px] bg-white p-4">
|
<div className="border border-[#00293d]/10 rounded-[20px] bg-white p-4">
|
||||||
{/* Text input area */}
|
{/* Text input area */}
|
||||||
<div className="border border-[#00293d]/10 rounded-[20px] p-4 mb-3">
|
<div className="border border-[#00293d]/10 rounded-[20px] p-4 mb-3">
|
||||||
<textarea
|
<textarea
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Write a Message........"
|
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"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
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
|
Send
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
import { ChatHeader } from './ChatHeader';
|
import { ChatHeader } from './ChatHeader';
|
||||||
import { MessageInput } from './MessageInput';
|
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
|
// Custom scrollbar styles
|
||||||
const customScrollbarStyles = `
|
const customScrollbarStyles = `
|
||||||
@@ -22,229 +39,251 @@ const customScrollbarStyles = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Types
|
// Helper to format date for conversation list
|
||||||
interface Conversation {
|
function formatConversationDate(dateString: string | null): string {
|
||||||
id: 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;
|
name: string;
|
||||||
avatar: string;
|
avatar: string | null;
|
||||||
lastMessage: string;
|
avatarUrl: string | null;
|
||||||
date: string;
|
headline: string | null;
|
||||||
isOnline: boolean;
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Message {
|
export function MessagingPage() {
|
||||||
id: string;
|
const { data: session } = useSession();
|
||||||
senderId: string;
|
const {
|
||||||
text: string;
|
conversations,
|
||||||
timestamp: string;
|
currentConversation,
|
||||||
isOwn: boolean;
|
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 [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
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 messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleSendMessage = (message: string) => {
|
const currentUserId = session?.user?.id;
|
||||||
console.log('Sending message:', message);
|
const userRole = session?.user?.role;
|
||||||
// In production, this would send the message to the API
|
|
||||||
|
// 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 = () => {
|
const handleScroll = () => {
|
||||||
if (messagesContainerRef.current) {
|
if (messagesContainerRef.current) {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
|
||||||
// Show button if not at bottom (with 100px threshold)
|
// Show button if not at bottom (with 100px threshold)
|
||||||
setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100);
|
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(() => {
|
useEffect(() => {
|
||||||
// Scroll to bottom on initial load
|
|
||||||
scrollToBottom();
|
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) =>
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{customScrollbarStyles}</style>
|
<style>{customScrollbarStyles}</style>
|
||||||
@@ -276,6 +347,14 @@ export function MessagingPage({
|
|||||||
Messaging
|
Messaging
|
||||||
</h1>
|
</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 */}
|
{/* Search bar */}
|
||||||
<div className="flex-1 max-w-[600px]">
|
<div className="flex-1 max-w-[600px]">
|
||||||
<div className="flex items-center gap-2 border border-[#00293d]/10 rounded-[15px] px-4 py-2">
|
<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]">
|
<div className="flex h-[700px]">
|
||||||
{/* Left sidebar - Conversation list */}
|
{/* Left sidebar - Conversation list */}
|
||||||
<div className="w-[380px] border-r border-[#00293d]/10 flex-shrink-0 overflow-hidden flex flex-col">
|
<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">
|
<div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
|
||||||
{filteredConversations.map((conversation) => (
|
{filteredConversations.map((conversation) => (
|
||||||
<div
|
<div
|
||||||
key={conversation.id}
|
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"
|
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)' }}
|
style={{ border: '1px solid rgba(0, 41, 61, 0.1)' }}
|
||||||
>
|
>
|
||||||
{/* Active indicator line */}
|
{/* 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]" />
|
<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 */}
|
{/* Avatar with online indicator */}
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20">
|
<div className="w-[70px] h-[70px] rounded-full overflow-hidden border border-[#00293D]/20">
|
||||||
<Image
|
<Image
|
||||||
src={conversation.avatar}
|
src={getValidAvatarUrl(conversationAvatarUrls[conversation.id])}
|
||||||
alt={conversation.name}
|
alt={conversation.otherParty?.name || 'User'}
|
||||||
width={70}
|
width={70}
|
||||||
height={70}
|
height={70}
|
||||||
className="w-full h-full object-cover rounded-full"
|
className="object-cover rounded-full"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 className="absolute bottom-1 right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-white" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-fractul font-medium text-[14px] leading-[17px] text-[#00293D] mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{conversation.name}
|
<p className="font-fractul font-medium text-[14px] leading-[17px] text-[#00293D]">
|
||||||
|
{conversation.otherParty?.name || 'Unknown'}
|
||||||
</p>
|
</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">
|
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] line-clamp-2">
|
||||||
{conversation.lastMessage}
|
{conversation.lastMessageText || 'No messages yet'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] flex-shrink-0">
|
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] flex-shrink-0">
|
||||||
{conversation.date}
|
{formatConversationDate(conversation.lastMessageAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel - Chat area */}
|
{/* Right panel - Chat area */}
|
||||||
<div className="flex-1 flex flex-col p-4">
|
<div className="flex-1 flex flex-col p-4">
|
||||||
{selectedConversation ? (
|
{currentConversation && selectedUserInfo ? (
|
||||||
<>
|
<>
|
||||||
{/* Chat header */}
|
{/* Chat header */}
|
||||||
<ChatHeader {...selectedUser} />
|
<ChatHeader
|
||||||
|
{...selectedUserInfo}
|
||||||
|
isTyping={typingUserNames.length > 0}
|
||||||
|
typingText={typingUserNames.length > 0 ? `${typingUserNames.join(', ')} is typing...` : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Messages area wrapper */}
|
{/* Messages area wrapper */}
|
||||||
<div className="flex-1 relative">
|
<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
|
<div
|
||||||
ref={messagesContainerRef}
|
ref={messagesContainerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="absolute inset-0 overflow-y-auto py-4 custom-scrollbar"
|
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">
|
<div className="space-y-4 px-2">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => {
|
||||||
|
const isOwn = message.senderId === currentUserId;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex ${message.isOwn ? 'justify-end' : 'justify-start'}`}
|
className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`max-w-[70%] rounded-[15px] px-4 py-3 ${
|
className={`max-w-[70%] rounded-[15px] px-4 py-3 ${
|
||||||
message.isOwn
|
isOwn
|
||||||
? 'bg-[#e58625] text-[#00293D]'
|
? 'bg-[#e58625] text-[#00293D]'
|
||||||
: 'bg-[#00293d]/10 text-[#00293D]'
|
: 'bg-[#00293d]/10 text-[#00293D]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="font-serif font-normal text-[14px] leading-[19px]">
|
<p className="font-serif font-normal text-[14px] leading-[19px]">
|
||||||
{message.text}
|
{message.content}
|
||||||
</p>
|
</p>
|
||||||
|
<div className={`flex items-center gap-2 mt-1 ${isOwn ? 'justify-end' : 'justify-start'}`}>
|
||||||
<p
|
<p
|
||||||
className={`font-serif font-normal text-[12px] leading-[16px] mt-1 ${
|
className={`font-serif font-normal text-[12px] leading-[16px] ${
|
||||||
message.isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'
|
isOwn ? 'text-[#00293D]/70' : 'text-[#00293D]/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{message.timestamp}
|
{formatMessageTime(message.createdAt)}
|
||||||
</p>
|
</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>
|
||||||
))}
|
|
||||||
</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 && (
|
{showScrollButton && (
|
||||||
<button
|
<button
|
||||||
onClick={scrollToBottom}
|
onClick={scrollToBottom}
|
||||||
@@ -427,11 +647,22 @@ export function MessagingPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message input */}
|
{/* 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">
|
<div className="flex items-center justify-center h-full text-[#00293D]/50 font-serif flex-col gap-4">
|
||||||
Select a conversation to start messaging
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
462
src/hooks/useMessaging.ts
Normal file
462
src/hooks/useMessaging.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,3 +47,7 @@ export type {
|
|||||||
TwoFactorLoginResponse,
|
TwoFactorLoginResponse,
|
||||||
TwoFactorRequiredResponse,
|
TwoFactorRequiredResponse,
|
||||||
} from './two-factor.service';
|
} from './two-factor.service';
|
||||||
|
|
||||||
|
// Messaging Services
|
||||||
|
export { socketService } from './socket.service';
|
||||||
|
export { messagesService } from './messages.service';
|
||||||
|
|||||||
86
src/services/messages.service.ts
Normal file
86
src/services/messages.service.ts
Normal 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();
|
||||||
234
src/services/socket.service.ts
Normal file
234
src/services/socket.service.ts
Normal 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
108
src/types/messaging.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user