conversations, unread count, autoscrolling to recent messages, cursor in text bar
This commit is contained in:
@@ -3,6 +3,7 @@ const { Message, User } = require('../models');
|
|||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
|
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get all messages for the current user (inbox)
|
// 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
|
// Get sent messages
|
||||||
router.get('/sent', authenticateToken, async (req, res) => {
|
router.get('/sent', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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 { messageAPI } from '../services/api';
|
||||||
import { User, Message } from '../types';
|
import { User, Message } from '../types';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
@@ -9,9 +9,10 @@ interface ChatWindowProps {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
recipient: User;
|
recipient: User;
|
||||||
|
onMessagesRead?: (partnerId: string, count: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) => {
|
const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMessagesRead }) => {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const { isConnected, joinConversation, leaveConversation, onNewMessage, onUserTyping, emitTypingStart, emitTypingStop } = useSocket();
|
const { isConnected, joinConversation, leaveConversation, onNewMessage, onUserTyping, emitTypingStart, emitTypingStop } = useSocket();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
@@ -19,11 +20,18 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isRecipientTyping, setIsRecipientTyping] = useState(false);
|
const [isRecipientTyping, setIsRecipientTyping] = useState(false);
|
||||||
|
const [initialUnreadMessageIds, setInitialUnreadMessageIds] = useState<Set<string>>(new Set());
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (show) {
|
if (show) {
|
||||||
|
setHasScrolledToUnread(false); // Reset flag when opening chat
|
||||||
fetchMessages();
|
fetchMessages();
|
||||||
|
|
||||||
// Join conversation room when chat opens
|
// Join conversation room when chat opens
|
||||||
@@ -41,7 +49,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
}, [show, recipient.id, isConnected]);
|
}, [show, recipient.id, isConnected]);
|
||||||
|
|
||||||
// Create a stable callback for handling new messages
|
// 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);
|
console.log('[ChatWindow] Received new_message event:', message);
|
||||||
|
|
||||||
// Only add messages that are part of this conversation
|
// Only add messages that are part of this conversation
|
||||||
@@ -59,10 +67,25 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
console.log('[ChatWindow] Adding new message to chat');
|
console.log('[ChatWindow] Adding new message to chat');
|
||||||
return [...prevMessages, message];
|
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 {
|
} else {
|
||||||
console.log('[ChatWindow] Message not for this conversation, ignoring');
|
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
|
// Listen for new messages in real-time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -112,9 +135,38 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
};
|
};
|
||||||
}, [isConnected, show, recipient.id, onUserTyping]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
if (isAtBottom && hasScrolledToUnread) {
|
||||||
}, [messages, isRecipientTyping]);
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [messages, isRecipientTyping, isAtBottom, hasScrolledToUnread]);
|
||||||
|
|
||||||
const fetchMessages = async () => {
|
const fetchMessages = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -133,6 +185,29 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
setMessages(allMessages);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch messages:', error);
|
console.error('Failed to fetch messages:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -144,6 +219,22 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
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
|
// Handle typing indicators with debouncing
|
||||||
const handleTyping = useCallback(() => {
|
const handleTyping = useCallback(() => {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
@@ -201,6 +292,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
setNewMessage(messageContent); // Restore message on error
|
setNewMessage(messageContent); // Restore message on error
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
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<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<div
|
<div
|
||||||
className="p-3 overflow-auto flex-grow-1"
|
ref={messagesContainerRef}
|
||||||
style={{
|
onScroll={handleScroll}
|
||||||
|
className="p-3 overflow-auto flex-grow-1"
|
||||||
|
style={{
|
||||||
backgroundColor: '#f8f9fa',
|
backgroundColor: '#f8f9fa',
|
||||||
minHeight: 0
|
minHeight: 0
|
||||||
}}
|
}}
|
||||||
@@ -305,7 +404,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
formatDate(message.createdAt) !== formatDate(messages[index - 1].createdAt);
|
formatDate(message.createdAt) !== formatDate(messages[index - 1].createdAt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={message.id}>
|
<div key={message.id} ref={setMessageRef(message.id)}>
|
||||||
{showDate && (
|
{showDate && (
|
||||||
<div className="text-center my-3">
|
<div className="text-center my-3">
|
||||||
<small className="text-muted bg-white px-2 py-1 rounded">
|
<small className="text-muted bg-white px-2 py-1 rounded">
|
||||||
@@ -355,6 +454,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
|
|||||||
<form onSubmit={handleSend} className="border-top p-3 flex-shrink-0">
|
<form onSubmit={handleSend} className="border-top p-3 flex-shrink-0">
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
|
|||||||
@@ -1,52 +1,136 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { Conversation, Message, User } from '../types';
|
||||||
import { Message, User } from '../types';
|
|
||||||
import { messageAPI } from '../services/api';
|
import { messageAPI } from '../services/api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useSocket } from '../contexts/SocketContext';
|
import { useSocket } from '../contexts/SocketContext';
|
||||||
import ChatWindow from '../components/ChatWindow';
|
import ChatWindow from '../components/ChatWindow';
|
||||||
|
|
||||||
const Messages: React.FC = () => {
|
const Messages: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { isConnected, onNewMessage } = useSocket();
|
const { isConnected, onNewMessage, onMessageRead } = useSocket();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedRecipient, setSelectedRecipient] = useState<User | null>(null);
|
const [selectedRecipient, setSelectedRecipient] = useState<User | null>(null);
|
||||||
const [showChat, setShowChat] = useState(false);
|
const [showChat, setShowChat] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMessages();
|
fetchConversations();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Listen for new messages in real-time
|
// Listen for new messages and update conversations in real-time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
const cleanup = onNewMessage((newMessage: Message) => {
|
const cleanup = onNewMessage((newMessage: Message) => {
|
||||||
// Only add if this is a received message (user is the receiver)
|
console.log('[Messages] Received new message:', newMessage);
|
||||||
if (newMessage.receiverId === user?.id) {
|
|
||||||
setMessages((prevMessages) => {
|
setConversations((prevConversations) => {
|
||||||
// Check if message already exists (avoid duplicates)
|
// Determine conversation partner
|
||||||
if (prevMessages.some(m => m.id === newMessage.id)) {
|
const partnerId = newMessage.senderId === user?.id
|
||||||
return prevMessages;
|
? 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;
|
return cleanup;
|
||||||
}, [isConnected, user?.id, onNewMessage]);
|
}, [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 {
|
try {
|
||||||
const response = await messageAPI.getMessages();
|
const response = await messageAPI.getConversations();
|
||||||
setMessages(response.data);
|
setConversations(response.data);
|
||||||
|
console.log('[Messages] Fetched conversations:', response.data.length);
|
||||||
} catch (err: any) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -56,7 +140,7 @@ const Messages: React.FC = () => {
|
|||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
|
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
if (diffInHours < 24) {
|
if (diffInHours < 24) {
|
||||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
} else if (diffInHours < 48) {
|
} else if (diffInHours < 48) {
|
||||||
@@ -66,25 +150,29 @@ const Messages: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMessageClick = async (message: Message) => {
|
const handleConversationClick = (conversation: Conversation) => {
|
||||||
// Mark as read if unread
|
setSelectedRecipient(conversation.partner);
|
||||||
if (!message.isRead) {
|
setShowChat(true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open chat with sender
|
const handleMessagesRead = (partnerId: string, count: number) => {
|
||||||
if (message.sender) {
|
console.log(`[Messages] ${count} messages marked as read for partner ${partnerId}`);
|
||||||
setSelectedRecipient(message.sender);
|
|
||||||
setShowChat(true);
|
// 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) {
|
if (loading) {
|
||||||
@@ -99,7 +187,6 @@ const Messages: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mt-4">
|
<div className="container mt-4">
|
||||||
<div className="row justify-content-center">
|
<div className="row justify-content-center">
|
||||||
@@ -112,61 +199,80 @@ const Messages: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.length === 0 ? (
|
{conversations.length === 0 ? (
|
||||||
<div className="text-center py-5">
|
<div className="text-center py-5">
|
||||||
<i className="bi bi-envelope" style={{ fontSize: '3rem', color: '#ccc' }}></i>
|
<i className="bi bi-envelope" style={{ fontSize: '3rem', color: '#ccc' }}></i>
|
||||||
<p className="text-muted mt-2">No messages in your inbox</p>
|
<p className="text-muted mt-2">No conversations yet</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="list-group">
|
<div className="list-group">
|
||||||
{messages.map((message) => (
|
{conversations.map((conversation) => {
|
||||||
<div
|
const isUnread = conversation.unreadCount > 0;
|
||||||
key={message.id}
|
const isLastMessageFromPartner = conversation.lastMessage.senderId === conversation.partnerId;
|
||||||
className={`list-group-item list-group-item-action ${!message.isRead ? 'border-start border-primary border-4' : ''}`}
|
|
||||||
onClick={() => handleMessageClick(message)}
|
return (
|
||||||
style={{
|
<div
|
||||||
cursor: 'pointer',
|
key={conversation.partnerId}
|
||||||
backgroundColor: !message.isRead ? '#f0f7ff' : 'white'
|
className={`list-group-item list-group-item-action ${isUnread ? 'border-start border-primary border-4' : ''}`}
|
||||||
}}
|
onClick={() => handleConversationClick(conversation)}
|
||||||
>
|
style={{
|
||||||
<div className="d-flex w-100 justify-content-between">
|
cursor: 'pointer',
|
||||||
<div className="d-flex align-items-center">
|
backgroundColor: isUnread ? '#f0f7ff' : 'white'
|
||||||
{message.sender?.profileImage ? (
|
}}
|
||||||
<img
|
>
|
||||||
src={message.sender.profileImage}
|
<div className="d-flex w-100 justify-content-between align-items-start">
|
||||||
alt={`${message.sender.firstName} ${message.sender.lastName}`}
|
<div className="d-flex align-items-center flex-grow-1">
|
||||||
className="rounded-circle me-3"
|
{/* Profile Picture */}
|
||||||
style={{ width: '40px', height: '40px', objectFit: 'cover' }}
|
{conversation.partner.profileImage ? (
|
||||||
/>
|
<img
|
||||||
) : (
|
src={conversation.partner.profileImage}
|
||||||
<div
|
alt={`${conversation.partner.firstName} ${conversation.partner.lastName}`}
|
||||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
|
className="rounded-circle me-3"
|
||||||
style={{ width: '40px', height: '40px' }}
|
style={{ width: '50px', height: '50px', objectFit: 'cover' }}
|
||||||
>
|
/>
|
||||||
<i className="bi bi-person-fill text-white"></i>
|
) : (
|
||||||
|
<div
|
||||||
|
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
|
||||||
|
style={{ width: '50px', height: '50px' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-person-fill text-white"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-grow-1" style={{ minWidth: 0 }}>
|
||||||
|
{/* User Name and Unread Badge */}
|
||||||
|
<div className="d-flex align-items-center mb-1">
|
||||||
|
<h6 className={`mb-0 ${isUnread ? 'fw-bold' : ''}`}>
|
||||||
|
{conversation.partner.firstName} {conversation.partner.lastName}
|
||||||
|
</h6>
|
||||||
|
{isUnread && (
|
||||||
|
<span className="badge bg-primary ms-2">{conversation.unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Message Preview */}
|
||||||
|
<p
|
||||||
|
className={`mb-0 text-truncate ${isUnread && isLastMessageFromPartner ? 'fw-semibold' : 'text-muted'}`}
|
||||||
|
style={{ maxWidth: '100%' }}
|
||||||
|
>
|
||||||
|
{conversation.lastMessage.senderId === user?.id && (
|
||||||
|
<span className="me-1">You: </span>
|
||||||
|
)}
|
||||||
|
{conversation.lastMessage.content}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<div>
|
|
||||||
<div className="d-flex align-items-center">
|
{/* Timestamp */}
|
||||||
<h6 className={`mb-1 ${!message.isRead ? 'fw-bold' : ''}`}>
|
<div className="text-end ms-3" style={{ minWidth: 'fit-content' }}>
|
||||||
{message.sender?.firstName} {message.sender?.lastName}
|
<small className="text-muted d-block">
|
||||||
</h6>
|
{formatDate(conversation.lastMessageAt)}
|
||||||
{!message.isRead && (
|
|
||||||
<span className="badge bg-primary ms-2">New</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className={`mb-1 text-truncate ${!message.isRead ? 'fw-semibold' : ''}`} style={{ maxWidth: '400px' }}>
|
|
||||||
{message.subject}
|
|
||||||
</p>
|
|
||||||
<small className="text-muted text-truncate d-block" style={{ maxWidth: '400px' }}>
|
|
||||||
{message.content}
|
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<small className="text-muted">{formatDate(message.createdAt)}</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -175,15 +281,13 @@ const Messages: React.FC = () => {
|
|||||||
{selectedRecipient && (
|
{selectedRecipient && (
|
||||||
<ChatWindow
|
<ChatWindow
|
||||||
show={showChat}
|
show={showChat}
|
||||||
onClose={() => {
|
onClose={handleChatClose}
|
||||||
setShowChat(false);
|
|
||||||
setSelectedRecipient(null);
|
|
||||||
}}
|
|
||||||
recipient={selectedRecipient}
|
recipient={selectedRecipient}
|
||||||
|
onMessagesRead={handleMessagesRead}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Messages;
|
export default Messages;
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export const rentalAPI = {
|
|||||||
export const messageAPI = {
|
export const messageAPI = {
|
||||||
getMessages: () => api.get("/messages"),
|
getMessages: () => api.get("/messages"),
|
||||||
getSentMessages: () => api.get("/messages/sent"),
|
getSentMessages: () => api.get("/messages/sent"),
|
||||||
|
getConversations: () => api.get("/messages/conversations"),
|
||||||
getMessage: (id: string) => api.get(`/messages/${id}`),
|
getMessage: (id: string) => api.get(`/messages/${id}`),
|
||||||
sendMessage: (data: any) => api.post("/messages", data),
|
sendMessage: (data: any) => api.post("/messages", data),
|
||||||
markAsRead: (id: string) => api.put(`/messages/${id}/read`),
|
markAsRead: (id: string) => api.put(`/messages/${id}/read`),
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ export interface Message {
|
|||||||
updatedAt: string;
|
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 {
|
export interface Item {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user