real time messaging

This commit is contained in:
jackiettran
2025-11-08 18:20:02 -05:00
parent de32b68ec4
commit 7a5bff8f2b
19 changed files with 2046 additions and 20 deletions

View File

@@ -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

View File

@@ -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>

View 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;
}
}

View 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;