conversations, unread count, autoscrolling to recent messages, cursor in text bar

This commit is contained in:
jackiettran
2025-11-09 22:16:26 -05:00
parent 7a5bff8f2b
commit 3442e880d8
5 changed files with 415 additions and 101 deletions

View File

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

View File

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

View File

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

View File

@@ -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`),

View File

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