315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
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 { user } = useAuth();
|
|
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(() => {
|
|
fetchConversations();
|
|
}, []);
|
|
|
|
// Listen for new messages and update conversations in real-time
|
|
useEffect(() => {
|
|
if (!isConnected) return;
|
|
|
|
const cleanup = onNewMessage((newMessage: Message) => {
|
|
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++;
|
|
}
|
|
|
|
updated[existingIndex] = conv;
|
|
|
|
// Re-sort by most recent
|
|
updated.sort(
|
|
(a, b) =>
|
|
new Date(b.lastMessageAt).getTime() -
|
|
new Date(a.lastMessageAt).getTime()
|
|
);
|
|
|
|
return updated;
|
|
} else {
|
|
// New conversation - add to top
|
|
const partner =
|
|
newMessage.senderId === user?.id
|
|
? newMessage.receiver!
|
|
: newMessage.sender!;
|
|
|
|
if (!partner) {
|
|
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,
|
|
};
|
|
|
|
return [newConv, ...prevConversations];
|
|
}
|
|
});
|
|
});
|
|
|
|
return cleanup;
|
|
}, [isConnected, user?.id, onNewMessage]);
|
|
|
|
// Listen for read receipts and update unread counts
|
|
useEffect(() => {
|
|
if (!isConnected) return;
|
|
|
|
const cleanup = onMessageRead((data: any) => {
|
|
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.getConversations();
|
|
setConversations(response.data);
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.error || "Failed to fetch conversations");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
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) {
|
|
return "Yesterday";
|
|
} else {
|
|
return date.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleConversationClick = (conversation: Conversation) => {
|
|
setSelectedRecipient(conversation.partner);
|
|
setShowChat(true);
|
|
};
|
|
|
|
const handleMessagesRead = (partnerId: string, count: number) => {
|
|
// 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) {
|
|
return (
|
|
<div className="container mt-5">
|
|
<div className="text-center">
|
|
<div className="spinner-border" role="status">
|
|
<span className="visually-hidden">Loading...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container mt-4">
|
|
<div className="row justify-content-center">
|
|
<div className="col-md-8">
|
|
<h1 className="mb-4">Messages</h1>
|
|
|
|
{error && (
|
|
<div className="alert alert-danger" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{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 conversations yet</p>
|
|
</div>
|
|
) : (
|
|
<div className="list-group">
|
|
{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>
|
|
|
|
{/* Timestamp */}
|
|
<div
|
|
className="text-end ms-3"
|
|
style={{ minWidth: "fit-content" }}
|
|
>
|
|
<small className="text-muted d-block">
|
|
{formatDate(conversation.lastMessageAt)}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedRecipient && (
|
|
<ChatWindow
|
|
show={showChat}
|
|
onClose={handleChatClose}
|
|
recipient={selectedRecipient}
|
|
onMessagesRead={handleMessagesRead}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Messages;
|