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

@@ -1,52 +1,136 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Message, User } from '../types';
import { Conversation, Message, User } from '../types';
import { messageAPI } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useSocket } from '../contexts/SocketContext';
import ChatWindow from '../components/ChatWindow';
const Messages: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const { isConnected, onNewMessage } = useSocket();
const [messages, setMessages] = useState<Message[]>([]);
const { isConnected, onNewMessage, onMessageRead } = useSocket();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedRecipient, setSelectedRecipient] = useState<User | null>(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 (
<div className="container mt-4">
<div className="row justify-content-center">
@@ -112,61 +199,80 @@ const Messages: React.FC = () => {
</div>
)}
{messages.length === 0 ? (
{conversations.length === 0 ? (
<div className="text-center py-5">
<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 className="list-group">
{messages.map((message) => (
<div
key={message.id}
className={`list-group-item list-group-item-action ${!message.isRead ? 'border-start border-primary border-4' : ''}`}
onClick={() => handleMessageClick(message)}
style={{
cursor: 'pointer',
backgroundColor: !message.isRead ? '#f0f7ff' : 'white'
}}
>
<div className="d-flex w-100 justify-content-between">
<div className="d-flex align-items-center">
{message.sender?.profileImage ? (
<img
src={message.sender.profileImage}
alt={`${message.sender.firstName} ${message.sender.lastName}`}
className="rounded-circle me-3"
style={{ width: '40px', height: '40px', objectFit: 'cover' }}
/>
) : (
<div
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3"
style={{ width: '40px', height: '40px' }}
>
<i className="bi bi-person-fill text-white"></i>
{conversations.map((conversation) => {
const isUnread = conversation.unreadCount > 0;
const isLastMessageFromPartner = conversation.lastMessage.senderId === conversation.partnerId;
return (
<div
key={conversation.partnerId}
className={`list-group-item list-group-item-action ${isUnread ? 'border-start border-primary border-4' : ''}`}
onClick={() => handleConversationClick(conversation)}
style={{
cursor: 'pointer',
backgroundColor: isUnread ? '#f0f7ff' : 'white'
}}
>
<div className="d-flex w-100 justify-content-between align-items-start">
<div className="d-flex align-items-center flex-grow-1">
{/* Profile Picture */}
{conversation.partner.profileImage ? (
<img
src={conversation.partner.profileImage}
alt={`${conversation.partner.firstName} ${conversation.partner.lastName}`}
className="rounded-circle me-3"
style={{ width: '50px', height: '50px', objectFit: 'cover' }}
/>
) : (
<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 className="d-flex align-items-center">
<h6 className={`mb-1 ${!message.isRead ? 'fw-bold' : ''}`}>
{message.sender?.firstName} {message.sender?.lastName}
</h6>
{!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}
</div>
{/* Timestamp */}
<div className="text-end ms-3" style={{ minWidth: 'fit-content' }}>
<small className="text-muted d-block">
{formatDate(conversation.lastMessageAt)}
</small>
</div>
</div>
<small className="text-muted">{formatDate(message.createdAt)}</small>
</div>
</div>
))}
);
})}
</div>
)}
</div>
@@ -175,15 +281,13 @@ const Messages: React.FC = () => {
{selectedRecipient && (
<ChatWindow
show={showChat}
onClose={() => {
setShowChat(false);
setSelectedRecipient(null);
}}
onClose={handleChatClose}
recipient={selectedRecipient}
onMessagesRead={handleMessagesRead}
/>
)}
</div>
);
};
export default Messages;
export default Messages;