real time messaging
This commit is contained in:
137
frontend/package-lock.json
generated
137
frontend/package-lock.json
generated
@@ -26,6 +26,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stripe": "^18.4.0",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
@@ -3318,6 +3319,12 @@
|
||||
"@sinonjs/commons": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"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/@stripe/react-stripe-js": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.2.tgz",
|
||||
@@ -7183,6 +7190,66 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
|
||||
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.17.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"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/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.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||
@@ -15583,6 +15650,68 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs": {
|
||||
"version": "0.3.24",
|
||||
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
||||
@@ -18103,6 +18232,14 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stripe": "^18.4.0",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { SocketProvider } from './contexts/SocketContext';
|
||||
import Navbar from './components/Navbar';
|
||||
import Footer from './components/Footer';
|
||||
import AuthModal from './components/AuthModal';
|
||||
@@ -202,10 +203,20 @@ const AppContent: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AppWithSocket: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<SocketProvider isAuthenticated={!!user}>
|
||||
<AppContent />
|
||||
</SocketProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
<AppWithSocket />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { messageAPI } from '../services/api';
|
||||
import { User, Message } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
import TypingIndicator from './TypingIndicator';
|
||||
|
||||
interface ChatWindowProps {
|
||||
show: boolean;
|
||||
@@ -11,21 +13,108 @@ interface ChatWindowProps {
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) => {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { isConnected, joinConversation, leaveConversation, onNewMessage, onUserTyping, emitTypingStart, emitTypingStop } = useSocket();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isRecipientTyping, setIsRecipientTyping] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
fetchMessages();
|
||||
|
||||
// Join conversation room when chat opens
|
||||
if (isConnected) {
|
||||
joinConversation(recipient.id);
|
||||
}
|
||||
}
|
||||
}, [show, recipient.id]);
|
||||
|
||||
return () => {
|
||||
// Leave conversation room when chat closes
|
||||
if (isConnected) {
|
||||
leaveConversation(recipient.id);
|
||||
}
|
||||
};
|
||||
}, [show, recipient.id, isConnected]);
|
||||
|
||||
// Create a stable callback for handling new messages
|
||||
const handleNewMessage = useCallback((message: Message) => {
|
||||
console.log('[ChatWindow] Received new_message event:', message);
|
||||
|
||||
// Only add messages that are part of this conversation
|
||||
if (
|
||||
(message.senderId === recipient.id && message.receiverId === currentUser?.id) ||
|
||||
(message.senderId === currentUser?.id && message.receiverId === recipient.id)
|
||||
) {
|
||||
console.log('[ChatWindow] Message is for this conversation, adding to chat');
|
||||
setMessages((prevMessages) => {
|
||||
// Check if message already exists (avoid duplicates)
|
||||
if (prevMessages.some(m => m.id === message.id)) {
|
||||
console.log('[ChatWindow] Message already exists, skipping');
|
||||
return prevMessages;
|
||||
}
|
||||
console.log('[ChatWindow] Adding new message to chat');
|
||||
return [...prevMessages, message];
|
||||
});
|
||||
} else {
|
||||
console.log('[ChatWindow] Message not for this conversation, ignoring');
|
||||
}
|
||||
}, [recipient.id, currentUser?.id]);
|
||||
|
||||
// Listen for new messages in real-time
|
||||
useEffect(() => {
|
||||
console.log('[ChatWindow] Message listener useEffect running', { isConnected, show, recipientId: recipient.id });
|
||||
|
||||
if (!isConnected || !show) {
|
||||
console.log('[ChatWindow] Skipping listener setup:', { isConnected, show });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ChatWindow] Setting up message listener for recipient:', recipient.id);
|
||||
|
||||
const cleanup = onNewMessage(handleNewMessage);
|
||||
|
||||
return () => {
|
||||
console.log('[ChatWindow] Cleaning up message listener for recipient:', recipient.id);
|
||||
cleanup();
|
||||
};
|
||||
}, [isConnected, show, onNewMessage, handleNewMessage]);
|
||||
|
||||
// Listen for typing indicators
|
||||
useEffect(() => {
|
||||
if (!isConnected || !show) return;
|
||||
|
||||
const cleanup = onUserTyping((data) => {
|
||||
// Only show typing indicator for the current recipient
|
||||
if (data.userId === recipient.id) {
|
||||
setIsRecipientTyping(data.isTyping);
|
||||
|
||||
// Auto-hide typing indicator after 3 seconds of no activity
|
||||
if (data.isTyping) {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
setIsRecipientTyping(false);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isConnected, show, recipient.id, onUserTyping]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
}, [messages, isRecipientTyping]);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
@@ -55,10 +144,38 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Handle typing indicators with debouncing
|
||||
const handleTyping = useCallback(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
// Emit typing start
|
||||
emitTypingStart(recipient.id);
|
||||
|
||||
// Clear existing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set timeout to emit typing stop after 2 seconds of inactivity
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
emitTypingStop(recipient.id);
|
||||
}, 2000);
|
||||
}, [isConnected, recipient.id, emitTypingStart, emitTypingStop]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewMessage(e.target.value);
|
||||
handleTyping();
|
||||
};
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
|
||||
// Stop typing indicator
|
||||
if (isConnected) {
|
||||
emitTypingStop(recipient.id);
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
const messageContent = newMessage;
|
||||
setNewMessage(''); // Clear input immediately for better UX
|
||||
@@ -70,8 +187,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
content: messageContent
|
||||
});
|
||||
|
||||
// Add the new message to the list
|
||||
setMessages([...messages, response.data]);
|
||||
// Add message to sender's chat immediately for instant feedback
|
||||
// Socket will handle updating the receiver's chat
|
||||
setMessages((prevMessages) => {
|
||||
// Avoid duplicates
|
||||
if (prevMessages.some(m => m.id === response.data.id)) {
|
||||
return prevMessages;
|
||||
}
|
||||
return [...prevMessages, response.data];
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
setNewMessage(messageContent); // Restore message on error
|
||||
@@ -145,7 +269,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
)}
|
||||
<div>
|
||||
<h6 className="mb-0">{recipient.firstName} {recipient.lastName}</h6>
|
||||
<small className="opacity-75">@{recipient.username}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -216,6 +339,13 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Typing indicator */}
|
||||
{isRecipientTyping && (
|
||||
<TypingIndicator
|
||||
firstName={recipient.firstName}
|
||||
isVisible={isRecipientTyping}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
@@ -229,7 +359,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
||||
className="form-control"
|
||||
placeholder="Type a message..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { rentalAPI } from "../services/api";
|
||||
import { useSocket } from "../contexts/SocketContext";
|
||||
import { rentalAPI, messageAPI } from "../services/api";
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const { user, logout, openAuthModal } = useAuth();
|
||||
const { onNewMessage, onMessageRead } = useSocket();
|
||||
const navigate = useNavigate();
|
||||
const [searchFilters, setSearchFilters] = useState({
|
||||
search: "",
|
||||
location: "",
|
||||
});
|
||||
const [pendingRequestsCount, setPendingRequestsCount] = useState(0);
|
||||
const [unreadMessagesCount, setUnreadMessagesCount] = useState(0);
|
||||
|
||||
// Fetch pending rental requests count when user logs in
|
||||
useEffect(() => {
|
||||
@@ -41,6 +44,46 @@ const Navbar: React.FC = () => {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// Fetch unread messages count when user logs in
|
||||
useEffect(() => {
|
||||
const fetchUnreadCount = async () => {
|
||||
if (user) {
|
||||
try {
|
||||
const response = await messageAPI.getUnreadCount();
|
||||
setUnreadMessagesCount(response.data.count);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch unread message count:", error);
|
||||
}
|
||||
} else {
|
||||
setUnreadMessagesCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnreadCount();
|
||||
}, [user]);
|
||||
|
||||
// Listen for real-time message updates via socket
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Listen for new messages
|
||||
const cleanupNewMessage = onNewMessage((message: any) => {
|
||||
if (message.receiverId === user.id) {
|
||||
setUnreadMessagesCount((prev) => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for messages being read
|
||||
const cleanupMessageRead = onMessageRead(() => {
|
||||
setUnreadMessagesCount((prev) => Math.max(0, prev - 1));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupNewMessage();
|
||||
cleanupMessageRead();
|
||||
};
|
||||
}, [user, onNewMessage, onMessageRead]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/");
|
||||
@@ -155,7 +198,7 @@ const Navbar: React.FC = () => {
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", position: "relative" }}>
|
||||
{pendingRequestsCount > 0 && (
|
||||
{(pendingRequestsCount > 0 || unreadMessagesCount > 0) && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -177,7 +220,7 @@ const Navbar: React.FC = () => {
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{pendingRequestsCount}
|
||||
{pendingRequestsCount + unreadMessagesCount}
|
||||
</span>
|
||||
)}
|
||||
<i className="bi bi-person-circle me-1"></i>
|
||||
@@ -224,6 +267,11 @@ const Navbar: React.FC = () => {
|
||||
<li>
|
||||
<Link className="dropdown-item" to="/messages">
|
||||
<i className="bi bi-envelope me-2"></i>Messages
|
||||
{unreadMessagesCount > 0 && (
|
||||
<span className="badge bg-danger rounded-pill ms-2">
|
||||
{unreadMessagesCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
48
frontend/src/components/TypingIndicator.css
Normal file
48
frontend/src/components/TypingIndicator.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.typing-text {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.typing-dots .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #6c757d;
|
||||
border-radius: 50%;
|
||||
animation: typing-bounce 1.4s infinite ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.typing-dots .dot:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.typing-dots .dot:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-6px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
30
frontend/src/components/TypingIndicator.tsx
Normal file
30
frontend/src/components/TypingIndicator.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import './TypingIndicator.css';
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
firstName: string;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typing Indicator Component
|
||||
* Shows an animated "User is typing..." message
|
||||
*/
|
||||
const TypingIndicator: React.FC<TypingIndicatorProps> = ({ firstName, isVisible }) => {
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="typing-indicator">
|
||||
<span className="typing-text">{firstName} is typing</span>
|
||||
<div className="typing-dots">
|
||||
<span className="dot"></span>
|
||||
<span className="dot"></span>
|
||||
<span className="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypingIndicator;
|
||||
176
frontend/src/contexts/SocketContext.tsx
Normal file
176
frontend/src/contexts/SocketContext.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import socketService from '../services/socket';
|
||||
|
||||
/**
|
||||
* Socket Context Type
|
||||
*/
|
||||
interface SocketContextType {
|
||||
socket: Socket | null;
|
||||
isConnected: boolean;
|
||||
joinConversation: (otherUserId: string) => void;
|
||||
leaveConversation: (otherUserId: string) => void;
|
||||
emitTypingStart: (receiverId: string) => void;
|
||||
emitTypingStop: (receiverId: string) => void;
|
||||
emitMarkMessageRead: (messageId: string, senderId: string) => void;
|
||||
onNewMessage: (callback: (message: any) => void) => () => void;
|
||||
onMessageRead: (callback: (data: { messageId: string; readAt: string; readBy: string }) => void) => () => void;
|
||||
onUserTyping: (callback: (data: { userId: string; firstName: string; isTyping: boolean }) => void) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Socket Context
|
||||
*/
|
||||
const SocketContext = createContext<SocketContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Socket Provider Props
|
||||
*/
|
||||
interface SocketProviderProps {
|
||||
children: ReactNode;
|
||||
isAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket Provider Component
|
||||
* Manages socket connection lifecycle and provides socket functionality to children
|
||||
*/
|
||||
export const SocketProvider: React.FC<SocketProviderProps> = ({
|
||||
children,
|
||||
isAuthenticated = false
|
||||
}) => {
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
/**
|
||||
* Initialize socket connection when user is authenticated
|
||||
*/
|
||||
useEffect(() => {
|
||||
console.log('[SocketProvider] useEffect running', { isAuthenticated });
|
||||
|
||||
if (!isAuthenticated) {
|
||||
console.log('[SocketProvider] Not authenticated, skipping socket setup');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SocketProvider] Initializing socket connection');
|
||||
const newSocket = socketService.connect();
|
||||
setSocket(newSocket);
|
||||
|
||||
// Listen for connection status changes
|
||||
console.log('[SocketProvider] Setting up connection listener');
|
||||
const removeListener = socketService.addConnectionListener((connected) => {
|
||||
console.log('[SocketProvider] Connection status changed:', connected);
|
||||
setIsConnected(connected);
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
console.log('[SocketProvider] Cleaning up connection listener');
|
||||
removeListener();
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
/**
|
||||
* Disconnect socket when user logs out
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated && socket) {
|
||||
console.log('[SocketProvider] User logged out, disconnecting socket');
|
||||
socketService.disconnect();
|
||||
setSocket(null);
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, [isAuthenticated, socket]);
|
||||
|
||||
/**
|
||||
* Join a conversation room
|
||||
*/
|
||||
const joinConversation = useCallback((otherUserId: string) => {
|
||||
socketService.joinConversation(otherUserId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Leave a conversation room
|
||||
*/
|
||||
const leaveConversation = useCallback((otherUserId: string) => {
|
||||
socketService.leaveConversation(otherUserId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Emit typing start event
|
||||
*/
|
||||
const emitTypingStart = useCallback((receiverId: string) => {
|
||||
socketService.emitTypingStart(receiverId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Emit typing stop event
|
||||
*/
|
||||
const emitTypingStop = useCallback((receiverId: string) => {
|
||||
socketService.emitTypingStop(receiverId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Emit mark message as read event
|
||||
*/
|
||||
const emitMarkMessageRead = useCallback((messageId: string, senderId: string) => {
|
||||
socketService.emitMarkMessageRead(messageId, senderId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Listen for new messages
|
||||
*/
|
||||
const onNewMessage = useCallback((callback: (message: any) => void) => {
|
||||
return socketService.onNewMessage(callback);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Listen for message read events
|
||||
*/
|
||||
const onMessageRead = useCallback((callback: (data: { messageId: string; readAt: string; readBy: string }) => void) => {
|
||||
return socketService.onMessageRead(callback);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Listen for typing indicators
|
||||
*/
|
||||
const onUserTyping = useCallback((callback: (data: { userId: string; firstName: string; isTyping: boolean }) => void) => {
|
||||
return socketService.onUserTyping(callback);
|
||||
}, []);
|
||||
|
||||
const value: SocketContextType = {
|
||||
socket,
|
||||
isConnected,
|
||||
joinConversation,
|
||||
leaveConversation,
|
||||
emitTypingStart,
|
||||
emitTypingStop,
|
||||
emitMarkMessageRead,
|
||||
onNewMessage,
|
||||
onMessageRead,
|
||||
onUserTyping,
|
||||
};
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to use Socket Context
|
||||
* @throws Error if used outside of SocketProvider
|
||||
*/
|
||||
export const useSocket = (): SocketContextType => {
|
||||
const context = useContext(SocketContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useSocket must be used within a SocketProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export default SocketContext;
|
||||
@@ -3,11 +3,13 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Message } from '../types';
|
||||
import { messageAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
|
||||
const MessageDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { isConnected, onNewMessage } = useSocket();
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -18,6 +20,34 @@ const MessageDetail: React.FC = () => {
|
||||
fetchMessage();
|
||||
}, [id]);
|
||||
|
||||
// Listen for new replies in real-time
|
||||
useEffect(() => {
|
||||
if (!isConnected || !message) return;
|
||||
|
||||
const cleanup = onNewMessage((newMessage: Message) => {
|
||||
// Check if this is a reply to the current thread
|
||||
if (newMessage.parentMessageId === message.id) {
|
||||
setMessage((prevMessage) => {
|
||||
if (!prevMessage) return prevMessage;
|
||||
|
||||
// Check if reply already exists (avoid duplicates)
|
||||
const replies = prevMessage.replies || [];
|
||||
if (replies.some(r => r.id === newMessage.id)) {
|
||||
return prevMessage;
|
||||
}
|
||||
|
||||
// Add new reply to the thread
|
||||
return {
|
||||
...prevMessage,
|
||||
replies: [...replies, newMessage]
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [isConnected, message?.id, onNewMessage]);
|
||||
|
||||
const fetchMessage = async () => {
|
||||
try {
|
||||
const response = await messageAPI.getMessage(id!);
|
||||
@@ -38,7 +68,7 @@ const MessageDetail: React.FC = () => {
|
||||
|
||||
try {
|
||||
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
|
||||
await messageAPI.sendMessage({
|
||||
const response = await messageAPI.sendMessage({
|
||||
receiverId: recipientId,
|
||||
subject: `Re: ${message.subject}`,
|
||||
content: replyContent,
|
||||
@@ -46,7 +76,20 @@ const MessageDetail: React.FC = () => {
|
||||
});
|
||||
|
||||
setReplyContent('');
|
||||
fetchMessage(); // Refresh to show the new reply
|
||||
|
||||
// Note: Socket will automatically add the reply to the thread
|
||||
// But we add it manually for immediate feedback if socket is disconnected
|
||||
if (!isConnected) {
|
||||
setMessage((prevMessage) => {
|
||||
if (!prevMessage) return prevMessage;
|
||||
const replies = prevMessage.replies || [];
|
||||
return {
|
||||
...prevMessage,
|
||||
replies: [...replies, response.data]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
alert('Reply sent successfully!');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to send reply');
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Message, User } from '../types';
|
||||
import { messageAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
import ChatWindow from '../components/ChatWindow';
|
||||
|
||||
const Messages: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { isConnected, onNewMessage } = useSocket();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -18,6 +20,27 @@ const Messages: React.FC = () => {
|
||||
fetchMessages();
|
||||
}, []);
|
||||
|
||||
// Listen for new messages in real-time
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
const cleanup = onNewMessage((newMessage: Message) => {
|
||||
// Only add if this is a received message (user is the receiver)
|
||||
if (newMessage.receiverId === user?.id) {
|
||||
setMessages((prevMessages) => {
|
||||
// Check if message already exists (avoid duplicates)
|
||||
if (prevMessages.some(m => m.id === newMessage.id)) {
|
||||
return prevMessages;
|
||||
}
|
||||
// Add new message to the top of the inbox
|
||||
return [newMessage, ...prevMessages];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [isConnected, user?.id, onNewMessage]);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const response = await messageAPI.getMessages();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { User, Item } from '../types';
|
||||
import { userAPI, itemAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ChatWindow from '../components/ChatWindow';
|
||||
|
||||
const PublicProfile: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -12,6 +13,7 @@ const PublicProfile: React.FC = () => {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile();
|
||||
@@ -85,11 +87,10 @@ const PublicProfile: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<h3>{user.firstName} {user.lastName}</h3>
|
||||
<p className="text-muted">@{user.username}</p>
|
||||
{currentUser && currentUser.id !== user.id && (
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => navigate('/messages')}
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => setShowChat(true)}
|
||||
>
|
||||
<i className="bi bi-chat-dots-fill me-2"></i>Message
|
||||
</button>
|
||||
@@ -148,6 +149,15 @@ const PublicProfile: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ChatWindow popup */}
|
||||
{user && (
|
||||
<ChatWindow
|
||||
show={showChat}
|
||||
onClose={() => setShowChat(false)}
|
||||
recipient={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
314
frontend/src/services/socket.ts
Normal file
314
frontend/src/services/socket.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { io, Socket } from "socket.io-client";
|
||||
|
||||
/**
|
||||
* Socket event types for type safety
|
||||
*/
|
||||
export interface SocketEvents {
|
||||
// Incoming events (from server)
|
||||
new_message: (message: any) => void;
|
||||
message_read: (data: {
|
||||
messageId: string;
|
||||
readAt: string;
|
||||
readBy: string;
|
||||
}) => void;
|
||||
user_typing: (data: {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
isTyping: boolean;
|
||||
}) => void;
|
||||
conversation_joined: (data: {
|
||||
conversationRoom: string;
|
||||
otherUserId: string;
|
||||
}) => void;
|
||||
|
||||
// Connection events
|
||||
connect: () => void;
|
||||
disconnect: (reason: string) => void;
|
||||
connect_error: (error: Error) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket service for managing WebSocket connection
|
||||
* Implements singleton pattern to ensure only one socket instance
|
||||
*/
|
||||
class SocketService {
|
||||
private socket: Socket | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private connectionListeners: Array<(connected: boolean) => void> = [];
|
||||
|
||||
/**
|
||||
* Get the socket instance URL based on environment
|
||||
*/
|
||||
private getSocketUrl(): string {
|
||||
// Use environment variable or default to localhost:5001 (matches backend)
|
||||
return process.env.REACT_APP_BASE_URL || "http://localhost:5001";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and connect to the socket server
|
||||
* Authentication happens via cookies (sent automatically)
|
||||
*/
|
||||
connect(): Socket {
|
||||
if (this.socket?.connected) {
|
||||
console.log("[Socket] Already connected");
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
console.log("[Socket] Connecting to server...");
|
||||
|
||||
this.socket = io(this.getSocketUrl(), {
|
||||
withCredentials: true, // Send cookies for authentication
|
||||
transports: ["websocket", "polling"], // Try WebSocket first, fallback to polling
|
||||
path: "/socket.io", // Explicit Socket.io path
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.maxReconnectAttempts,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// Connection event handlers
|
||||
this.socket.on("connect", () => {
|
||||
console.log("[Socket] Connected successfully", {
|
||||
socketId: this.socket?.id,
|
||||
});
|
||||
this.reconnectAttempts = 0;
|
||||
this.notifyConnectionListeners(true);
|
||||
});
|
||||
|
||||
this.socket.on("disconnect", (reason) => {
|
||||
console.log("[Socket] Disconnected", { reason });
|
||||
this.notifyConnectionListeners(false);
|
||||
});
|
||||
|
||||
this.socket.on("connect_error", (error) => {
|
||||
console.error("[Socket] Connection error", {
|
||||
error: error.message,
|
||||
attempt: this.reconnectAttempts + 1,
|
||||
});
|
||||
this.reconnectAttempts++;
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error("[Socket] Max reconnection attempts reached");
|
||||
this.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("error", (error) => {
|
||||
console.error("[Socket] Socket error", error);
|
||||
});
|
||||
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the socket server
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.socket) {
|
||||
console.log("[Socket] Disconnecting...");
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.notifyConnectionListeners(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current socket instance
|
||||
*/
|
||||
getSocket(): Socket | null {
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if socket is connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.socket?.connected ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a conversation room
|
||||
*/
|
||||
joinConversation(otherUserId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
console.warn("[Socket] Not connected, cannot join conversation");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Socket] Joining conversation", { otherUserId });
|
||||
this.socket.emit("join_conversation", { otherUserId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a conversation room
|
||||
*/
|
||||
leaveConversation(otherUserId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Socket] Leaving conversation", { otherUserId });
|
||||
this.socket.emit("leave_conversation", { otherUserId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit typing start event
|
||||
*/
|
||||
emitTypingStart(receiverId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit("typing_start", { receiverId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit typing stop event
|
||||
*/
|
||||
emitTypingStop(receiverId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit("typing_stop", { receiverId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit mark message as read event
|
||||
*/
|
||||
emitMarkMessageRead(messageId: string, senderId: string): void {
|
||||
if (!this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit("mark_message_read", { messageId, senderId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for new messages
|
||||
*/
|
||||
onNewMessage(callback: (message: any) => void): () => void {
|
||||
if (!this.socket) {
|
||||
console.warn("[Socket] Socket not initialized");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.socket.on("new_message", callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.socket?.off("new_message", callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for message read events
|
||||
*/
|
||||
onMessageRead(
|
||||
callback: (data: {
|
||||
messageId: string;
|
||||
readAt: string;
|
||||
readBy: string;
|
||||
}) => void
|
||||
): () => void {
|
||||
if (!this.socket) {
|
||||
console.warn("[Socket] Socket not initialized");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.socket.on("message_read", callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.socket?.off("message_read", callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for typing indicators
|
||||
*/
|
||||
onUserTyping(
|
||||
callback: (data: {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
isTyping: boolean;
|
||||
}) => void
|
||||
): () => void {
|
||||
if (!this.socket) {
|
||||
console.warn("[Socket] Socket not initialized");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.socket.on("user_typing", callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.socket?.off("user_typing", callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for conversation joined event
|
||||
*/
|
||||
onConversationJoined(
|
||||
callback: (data: { conversationRoom: string; otherUserId: string }) => void
|
||||
): () => void {
|
||||
if (!this.socket) {
|
||||
console.warn("[Socket] Socket not initialized");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.socket.on("conversation_joined", callback);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.socket?.off("conversation_joined", callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add connection status listener
|
||||
*/
|
||||
addConnectionListener(callback: (connected: boolean) => void): () => void {
|
||||
this.connectionListeners.push(callback);
|
||||
|
||||
// Immediately notify of current status
|
||||
callback(this.isConnected());
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
this.connectionListeners = this.connectionListeners.filter(
|
||||
(cb) => cb !== callback
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all connection listeners of status change
|
||||
*/
|
||||
private notifyConnectionListeners(connected: boolean): void {
|
||||
this.connectionListeners.forEach((callback) => {
|
||||
try {
|
||||
callback(connected);
|
||||
} catch (error) {
|
||||
console.error("[Socket] Error in connection listener", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all event listeners
|
||||
*/
|
||||
removeAllListeners(): void {
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const socketService = new SocketService();
|
||||
export default socketService;
|
||||
Reference in New Issue
Block a user