diff --git a/backend/routes/messages.js b/backend/routes/messages.js index b0d04a2..8ce6e85 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -3,6 +3,7 @@ const { Message, User } = require('../models'); const { authenticateToken } = require('../middleware/auth'); const logger = require('../utils/logger'); const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket'); +const { Op } = require('sequelize'); const router = express.Router(); // Get all messages for the current user (inbox) @@ -38,6 +39,100 @@ router.get('/', authenticateToken, async (req, res) => { } }); +// Get conversations grouped by user pairs +router.get('/conversations', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + + // Fetch all messages where user is sender or receiver + const allMessages = await Message.findAll({ + where: { + [Op.or]: [ + { senderId: userId }, + { receiverId: userId } + ] + }, + include: [ + { + model: User, + as: 'sender', + attributes: ['id', 'firstName', 'lastName', 'profileImage'] + }, + { + model: User, + as: 'receiver', + attributes: ['id', 'firstName', 'lastName', 'profileImage'] + } + ], + order: [['createdAt', 'DESC']] + }); + + // Group messages by conversation partner + const conversationsMap = new Map(); + + allMessages.forEach(message => { + // Determine the conversation partner + const partnerId = message.senderId === userId ? message.receiverId : message.senderId; + const partner = message.senderId === userId ? message.receiver : message.sender; + + if (!conversationsMap.has(partnerId)) { + conversationsMap.set(partnerId, { + partnerId, + partner: partner ? { + id: partner.id, + firstName: partner.firstName, + lastName: partner.lastName, + profileImage: partner.profileImage + } : null, + lastMessage: null, + lastMessageAt: null, + unreadCount: 0 + }); + } + + const conversation = conversationsMap.get(partnerId); + + // Count unread messages (only those received by current user) + if (message.receiverId === userId && !message.isRead) { + conversation.unreadCount++; + } + + // Keep the most recent message (messages are already sorted DESC) + if (!conversation.lastMessage) { + conversation.lastMessage = { + id: message.id, + content: message.content, + senderId: message.senderId, + createdAt: message.createdAt, + isRead: message.isRead + }; + conversation.lastMessageAt = message.createdAt; + } + }); + + // Convert to array and sort by most recent message first + const conversations = Array.from(conversationsMap.values()) + .filter(conv => conv.partner !== null) // Filter out conversations with deleted users + .sort((a, b) => new Date(b.lastMessageAt) - new Date(a.lastMessageAt)); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Conversations fetched", { + userId: req.user.id, + conversationCount: conversations.length + }); + + res.json(conversations); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Conversations fetch failed", { + error: error.message, + stack: error.stack, + userId: req.user.id + }); + res.status(500).json({ error: error.message }); + } +}); + // Get sent messages router.get('/sent', authenticateToken, async (req, res) => { try { diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx index 47f382b..61c0a79 100644 --- a/frontend/src/components/ChatWindow.tsx +++ b/frontend/src/components/ChatWindow.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { messageAPI } from '../services/api'; import { User, Message } from '../types'; import { useAuth } from '../contexts/AuthContext'; @@ -9,9 +9,10 @@ interface ChatWindowProps { show: boolean; onClose: () => void; recipient: User; + onMessagesRead?: (partnerId: string, count: number) => void; } -const ChatWindow: React.FC = ({ show, onClose, recipient }) => { +const ChatWindow: React.FC = ({ show, onClose, recipient, onMessagesRead }) => { const { user: currentUser } = useAuth(); const { isConnected, joinConversation, leaveConversation, onNewMessage, onUserTyping, emitTypingStart, emitTypingStop } = useSocket(); const [messages, setMessages] = useState([]); @@ -19,11 +20,18 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) => const [sending, setSending] = useState(false); const [loading, setLoading] = useState(true); const [isRecipientTyping, setIsRecipientTyping] = useState(false); + const [initialUnreadMessageIds, setInitialUnreadMessageIds] = useState>(new Set()); + const [isAtBottom, setIsAtBottom] = useState(true); + const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false); const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const messageRefs = useRef>(new Map()); const typingTimeoutRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { if (show) { + setHasScrolledToUnread(false); // Reset flag when opening chat fetchMessages(); // Join conversation room when chat opens @@ -41,7 +49,7 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) => }, [show, recipient.id, isConnected]); // Create a stable callback for handling new messages - const handleNewMessage = useCallback((message: Message) => { + const handleNewMessage = useCallback(async (message: Message) => { console.log('[ChatWindow] Received new_message event:', message); // Only add messages that are part of this conversation @@ -59,10 +67,25 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) => console.log('[ChatWindow] Adding new message to chat'); return [...prevMessages, message]; }); + + // Mark incoming messages from recipient as read + if (message.senderId === recipient.id && message.receiverId === currentUser?.id && !message.isRead) { + console.log('[ChatWindow] Marking new incoming message as read'); + try { + await messageAPI.markAsRead(message.id); + + // Notify parent component that message was marked read + if (onMessagesRead) { + onMessagesRead(recipient.id, 1); + } + } catch (error) { + console.error(`Failed to mark message ${message.id} as read:`, error); + } + } } else { console.log('[ChatWindow] Message not for this conversation, ignoring'); } - }, [recipient.id, currentUser?.id]); + }, [recipient.id, currentUser?.id, onMessagesRead]); // Listen for new messages in real-time useEffect(() => { @@ -112,9 +135,38 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) => }; }, [isConnected, show, recipient.id, onUserTyping]); + // Initial scroll to unread messages (runs synchronously after DOM updates, refs are ready) + useLayoutEffect(() => { + if (!loading && !hasScrolledToUnread && messages.length > 0) { + if (initialUnreadMessageIds.size > 0) { + // Find the oldest unread message + const oldestUnread = messages.find(m => initialUnreadMessageIds.has(m.id)); + + if (oldestUnread && messageRefs.current.has(oldestUnread.id)) { + console.log(`[ChatWindow] Scrolling to oldest unread message: ${oldestUnread.id}`); + messageRefs.current.get(oldestUnread.id)?.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } else { + console.log('[ChatWindow] Unread message ref not found, scrolling to bottom'); + scrollToBottom(); + } + } else { + // No unread messages, scroll to bottom + console.log('[ChatWindow] No unread messages, scrolling to bottom'); + scrollToBottom(); + } + setHasScrolledToUnread(true); + } + }, [loading, hasScrolledToUnread, messages, initialUnreadMessageIds]); + + // Auto-scroll for new messages (only if user is at bottom) useEffect(() => { - scrollToBottom(); - }, [messages, isRecipientTyping]); + if (isAtBottom && hasScrolledToUnread) { + scrollToBottom(); + } + }, [messages, isRecipientTyping, isAtBottom, hasScrolledToUnread]); const fetchMessages = async () => { try { @@ -133,6 +185,29 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) => ); setMessages(allMessages); + + // Mark all unread messages from recipient as read + const unreadMessages = receivedFromRecipient.filter((msg: Message) => !msg.isRead); + if (unreadMessages.length > 0) { + console.log(`[ChatWindow] Marking ${unreadMessages.length} messages as read`); + + // Save unread message IDs for scrolling purposes + setInitialUnreadMessageIds(new Set(unreadMessages.map((m: Message) => m.id))); + + // Mark each message as read + const markReadPromises = unreadMessages.map((message: Message) => + messageAPI.markAsRead(message.id).catch((err: any) => { + console.error(`Failed to mark message ${message.id} as read:`, err); + }) + ); + + await Promise.all(markReadPromises); + + // Notify parent component that messages were marked read + if (onMessagesRead) { + onMessagesRead(recipient.id, unreadMessages.length); + } + } } catch (error) { console.error('Failed to fetch messages:', error); } finally { @@ -144,6 +219,22 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; + const setMessageRef = useCallback((id: string) => (el: HTMLDivElement | null) => { + if (el) { + messageRefs.current.set(id, el); + } else { + messageRefs.current.delete(id); + } + }, []); + + const handleScroll = () => { + if (!messagesContainerRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; + const isBottom = scrollHeight - scrollTop - clientHeight < 50; // 50px threshold + setIsAtBottom(isBottom); + }; + // Handle typing indicators with debouncing const handleTyping = useCallback(() => { if (!isConnected) return; @@ -201,6 +292,12 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) => setNewMessage(messageContent); // Restore message on error } finally { setSending(false); + // Defer focus until after all DOM updates and scroll operations complete + requestAnimationFrame(() => { + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }); } }; @@ -279,9 +376,11 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) => {/* Messages Area */} -
= ({ show, onClose, recipient }) => formatDate(message.createdAt) !== formatDate(messages[index - 1].createdAt); return ( -
+
{showDate && (
@@ -355,6 +454,7 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) =>
{ - const navigate = useNavigate(); const { user } = useAuth(); - const { isConnected, onNewMessage } = useSocket(); - const [messages, setMessages] = useState([]); + const { isConnected, onNewMessage, onMessageRead } = useSocket(); + const [conversations, setConversations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedRecipient, setSelectedRecipient] = useState(null); const [showChat, setShowChat] = useState(false); useEffect(() => { - fetchMessages(); + fetchConversations(); }, []); - // Listen for new messages in real-time + // Listen for new messages and update conversations 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; + console.log('[Messages] Received new message:', newMessage); + + setConversations((prevConversations) => { + // Determine conversation partner + const partnerId = newMessage.senderId === user?.id + ? newMessage.receiverId + : newMessage.senderId; + + // Find existing conversation + const existingIndex = prevConversations.findIndex( + c => c.partnerId === partnerId + ); + + if (existingIndex !== -1) { + // Update existing conversation + const updated = [...prevConversations]; + const conv = { ...updated[existingIndex] }; + + conv.lastMessage = { + id: newMessage.id, + content: newMessage.content, + senderId: newMessage.senderId, + createdAt: newMessage.createdAt, + isRead: newMessage.isRead + }; + conv.lastMessageAt = newMessage.createdAt; + + // Increment unread count if user received the message + if (newMessage.receiverId === user?.id && !newMessage.isRead) { + conv.unreadCount++; } - // Add new message to the top of the inbox - return [newMessage, ...prevMessages]; - }); - } + + updated[existingIndex] = conv; + + // Re-sort by most recent + updated.sort((a, b) => + new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime() + ); + + console.log('[Messages] Updated existing conversation'); + return updated; + } else { + // New conversation - add to top + const partner = newMessage.senderId === user?.id + ? newMessage.receiver! + : newMessage.sender!; + + if (!partner) { + console.warn('[Messages] Partner data missing from new message'); + return prevConversations; + } + + const newConv: Conversation = { + partnerId, + partner, + lastMessage: { + id: newMessage.id, + content: newMessage.content, + senderId: newMessage.senderId, + createdAt: newMessage.createdAt, + isRead: newMessage.isRead + }, + unreadCount: newMessage.receiverId === user?.id && !newMessage.isRead ? 1 : 0, + lastMessageAt: newMessage.createdAt + }; + + console.log('[Messages] Created new conversation'); + return [newConv, ...prevConversations]; + } + }); }); return cleanup; }, [isConnected, user?.id, onNewMessage]); - const fetchMessages = async () => { + // Listen for read receipts and update unread counts + useEffect(() => { + if (!isConnected) return; + + const cleanup = onMessageRead((data: any) => { + console.log('[Messages] Message read:', data); + + setConversations((prevConversations) => { + return prevConversations.map(conv => { + // If this is the conversation and the last message was marked as read + if (conv.lastMessage.id === data.messageId && !conv.lastMessage.isRead) { + return { + ...conv, + lastMessage: { ...conv.lastMessage, isRead: true }, + unreadCount: Math.max(0, conv.unreadCount - 1) + }; + } + return conv; + }); + }); + }); + + return cleanup; + }, [isConnected, onMessageRead]); + + const fetchConversations = async () => { try { - const response = await messageAPI.getMessages(); - setMessages(response.data); + const response = await messageAPI.getConversations(); + setConversations(response.data); + console.log('[Messages] Fetched conversations:', response.data.length); } catch (err: any) { - setError(err.response?.data?.error || 'Failed to fetch messages'); + console.error('[Messages] Failed to fetch conversations:', err); + setError(err.response?.data?.error || 'Failed to fetch conversations'); } finally { setLoading(false); } @@ -56,7 +140,7 @@ const Messages: React.FC = () => { const date = new Date(dateString); const now = new Date(); const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60); - + if (diffInHours < 24) { return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } else if (diffInHours < 48) { @@ -66,25 +150,29 @@ const Messages: React.FC = () => { } }; - const handleMessageClick = async (message: Message) => { - // Mark as read if unread - if (!message.isRead) { - try { - await messageAPI.markAsRead(message.id); - // Update local state - setMessages(messages.map(m => - m.id === message.id ? { ...m, isRead: true } : m - )); - } catch (err) { - console.error('Failed to mark message as read:', err); - } - } + const handleConversationClick = (conversation: Conversation) => { + setSelectedRecipient(conversation.partner); + setShowChat(true); + }; - // Open chat with sender - if (message.sender) { - setSelectedRecipient(message.sender); - setShowChat(true); - } + const handleMessagesRead = (partnerId: string, count: number) => { + console.log(`[Messages] ${count} messages marked as read for partner ${partnerId}`); + + // Update the conversation's unread count + setConversations(prevConversations => + prevConversations.map(conv => + conv.partnerId === partnerId + ? { ...conv, unreadCount: 0 } + : conv + ) + ); + }; + + const handleChatClose = () => { + setShowChat(false); + setSelectedRecipient(null); + // Refresh conversations to get updated unread counts + fetchConversations(); }; if (loading) { @@ -99,7 +187,6 @@ const Messages: React.FC = () => { ); } - return (
@@ -112,61 +199,80 @@ const Messages: React.FC = () => {
)} - {messages.length === 0 ? ( + {conversations.length === 0 ? (
-

No messages in your inbox

+

No conversations yet

) : (
- {messages.map((message) => ( -
handleMessageClick(message)} - style={{ - cursor: 'pointer', - backgroundColor: !message.isRead ? '#f0f7ff' : 'white' - }} - > -
-
- {message.sender?.profileImage ? ( - {`${message.sender.firstName} - ) : ( -
- + {conversations.map((conversation) => { + const isUnread = conversation.unreadCount > 0; + const isLastMessageFromPartner = conversation.lastMessage.senderId === conversation.partnerId; + + return ( +
handleConversationClick(conversation)} + style={{ + cursor: 'pointer', + backgroundColor: isUnread ? '#f0f7ff' : 'white' + }} + > +
+
+ {/* Profile Picture */} + {conversation.partner.profileImage ? ( + {`${conversation.partner.firstName} + ) : ( +
+ +
+ )} + +
+ {/* User Name and Unread Badge */} +
+
+ {conversation.partner.firstName} {conversation.partner.lastName} +
+ {isUnread && ( + {conversation.unreadCount} + )} +
+ + {/* Last Message Preview */} +

+ {conversation.lastMessage.senderId === user?.id && ( + You: + )} + {conversation.lastMessage.content} +

- )} -
-
-
- {message.sender?.firstName} {message.sender?.lastName} -
- {!message.isRead && ( - New - )} -
-

- {message.subject} -

- - {message.content} +
+ + {/* Timestamp */} +
+ + {formatDate(conversation.lastMessageAt)}
- {formatDate(message.createdAt)}
-
- ))} + ); + })}
)}
@@ -175,15 +281,13 @@ const Messages: React.FC = () => { {selectedRecipient && ( { - setShowChat(false); - setSelectedRecipient(null); - }} + onClose={handleChatClose} recipient={selectedRecipient} + onMessagesRead={handleMessagesRead} /> )}
); }; -export default Messages; \ No newline at end of file +export default Messages; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index fe354e1..a2c00ee 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -239,6 +239,7 @@ export const rentalAPI = { export const messageAPI = { getMessages: () => api.get("/messages"), getSentMessages: () => api.get("/messages/sent"), + getConversations: () => api.get("/messages/conversations"), getMessage: (id: string) => api.get(`/messages/${id}`), sendMessage: (data: any) => api.post("/messages", data), markAsRead: (id: string) => api.put(`/messages/${id}/read`), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ed84974..b5ee6f7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -48,6 +48,20 @@ export interface Message { updatedAt: string; } +export interface Conversation { + partnerId: string; + partner: User; + lastMessage: { + id: string; + content: string; + senderId: string; + createdAt: string; + isRead: boolean; + }; + unreadCount: number; + lastMessageAt: string; +} + export interface Item { id: string; name: string;