real time messaging
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user