simplified message model
This commit is contained in:
61
backend/migrations/20241124000007-create-messages.js
Normal file
61
backend/migrations/20241124000007-create-messages.js
Normal file
@@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable("Messages", {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
senderId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "Users",
|
||||
key: "id",
|
||||
},
|
||||
onUpdate: "CASCADE",
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
receiverId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "Users",
|
||||
key: "id",
|
||||
},
|
||||
onUpdate: "CASCADE",
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
content: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
isRead: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false,
|
||||
},
|
||||
imagePath: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Add indexes
|
||||
await queryInterface.addIndex("Messages", ["senderId"]);
|
||||
await queryInterface.addIndex("Messages", ["receiverId"]);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable("Messages");
|
||||
},
|
||||
};
|
||||
@@ -23,10 +23,6 @@ const Message = sequelize.define('Message', {
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
subject: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
content: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
@@ -35,14 +31,6 @@ const Message = sequelize.define('Message', {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
parentMessageId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'Messages',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
imagePath: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
|
||||
@@ -27,11 +27,6 @@ User.hasMany(Message, { as: "sentMessages", foreignKey: "senderId" });
|
||||
User.hasMany(Message, { as: "receivedMessages", foreignKey: "receiverId" });
|
||||
Message.belongsTo(User, { as: "sender", foreignKey: "senderId" });
|
||||
Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" });
|
||||
Message.hasMany(Message, { as: "replies", foreignKey: "parentMessageId" });
|
||||
Message.belongsTo(Message, {
|
||||
as: "parentMessage",
|
||||
foreignKey: "parentMessageId",
|
||||
});
|
||||
|
||||
// Forum associations
|
||||
User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" });
|
||||
|
||||
@@ -171,7 +171,7 @@ router.get('/sent', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single message with replies
|
||||
// Get a single message
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const message = await Message.findOne({
|
||||
@@ -192,15 +192,6 @@ router.get('/:id', authenticateToken, async (req, res) => {
|
||||
model: User,
|
||||
as: 'receiver',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
},
|
||||
{
|
||||
model: Message,
|
||||
as: 'replies',
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'sender',
|
||||
attributes: ['id', 'firstName', 'lastName', 'profileImage']
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -248,7 +239,7 @@ router.get('/:id', authenticateToken, async (req, res) => {
|
||||
// Send a new message
|
||||
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||
try {
|
||||
const { receiverId, subject, content, parentMessageId } = req.body;
|
||||
const { receiverId, content } = req.body;
|
||||
|
||||
// Check if receiver exists
|
||||
const receiver = await User.findByPk(receiverId);
|
||||
@@ -267,9 +258,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||
const message = await Message.create({
|
||||
senderId: req.user.id,
|
||||
receiverId,
|
||||
subject,
|
||||
content,
|
||||
parentMessageId,
|
||||
imagePath
|
||||
});
|
||||
|
||||
@@ -308,8 +297,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||
reqLogger.info("Message sent", {
|
||||
senderId: req.user.id,
|
||||
receiverId: receiverId,
|
||||
messageId: message.id,
|
||||
isReply: !!parentMessageId
|
||||
messageId: message.id
|
||||
});
|
||||
|
||||
res.status(201).json(messageWithSender);
|
||||
|
||||
@@ -39,7 +39,6 @@ class MessagingEmailService {
|
||||
* @param {string} sender.firstName - Sender's first name
|
||||
* @param {string} sender.lastName - Sender's last name
|
||||
* @param {Object} message - Message object
|
||||
* @param {string} message.subject - Message subject
|
||||
* @param {string} message.content - Message content
|
||||
* @param {Date} message.createdAt - Message creation timestamp
|
||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||
@@ -61,7 +60,6 @@ class MessagingEmailService {
|
||||
const variables = {
|
||||
recipientName: receiver.firstName || "there",
|
||||
senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user",
|
||||
subject: message.subject,
|
||||
messageContent: message.content,
|
||||
conversationUrl: conversationUrl,
|
||||
timestamp: timestamp,
|
||||
|
||||
@@ -134,13 +134,6 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.message-box .subject {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.message-box .content-text {
|
||||
color: #212529;
|
||||
line-height: 1.6;
|
||||
@@ -226,7 +219,6 @@
|
||||
<p>{{senderName}} sent you a message on RentAll.</p>
|
||||
|
||||
<div class="message-box">
|
||||
<div class="subject">Subject: {{subject}}</div>
|
||||
<div class="content-text">{{messageContent}}</div>
|
||||
<div class="timestamp">Sent {{timestamp}}</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,6 @@ describe('Messages Routes', () => {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
@@ -71,7 +70,6 @@ describe('Messages Routes', () => {
|
||||
id: 2,
|
||||
senderId: 3,
|
||||
receiverId: 1,
|
||||
subject: 'Another Message',
|
||||
content: 'Hi!',
|
||||
isRead: true,
|
||||
createdAt: '2024-01-14T10:00:00.000Z',
|
||||
@@ -122,7 +120,6 @@ describe('Messages Routes', () => {
|
||||
id: 3,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'My Message',
|
||||
content: 'Hello Jane!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T12:00:00.000Z',
|
||||
@@ -171,7 +168,6 @@ describe('Messages Routes', () => {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
@@ -187,19 +183,6 @@ describe('Messages Routes', () => {
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 4,
|
||||
senderId: 1,
|
||||
content: 'Reply message',
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
}
|
||||
],
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
@@ -207,7 +190,7 @@ describe('Messages Routes', () => {
|
||||
mockMessageFindOne.mockResolvedValue(mockMessage);
|
||||
});
|
||||
|
||||
it('should get message with replies for receiver', async () => {
|
||||
it('should get message for receiver and mark as read', async () => {
|
||||
mockMessage.update.mockResolvedValue();
|
||||
|
||||
const response = await request(app)
|
||||
@@ -218,7 +201,6 @@ describe('Messages Routes', () => {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
@@ -233,20 +215,7 @@ describe('Messages Routes', () => {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 4,
|
||||
senderId: 1,
|
||||
content: 'Reply message',
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
|
||||
});
|
||||
@@ -263,7 +232,6 @@ describe('Messages Routes', () => {
|
||||
id: 1,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
createdAt: '2024-01-15T10:00:00.000Z',
|
||||
@@ -278,20 +246,7 @@ describe('Messages Routes', () => {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 4,
|
||||
senderId: 1,
|
||||
content: 'Reply message',
|
||||
sender: {
|
||||
id: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
profileImage: 'john.jpg'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(mockMessage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -340,9 +295,7 @@ describe('Messages Routes', () => {
|
||||
id: 5,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'New Message',
|
||||
content: 'Hello Jane!',
|
||||
parentMessageId: null
|
||||
content: 'Hello Jane!'
|
||||
};
|
||||
|
||||
const mockMessageWithSender = {
|
||||
@@ -364,9 +317,7 @@ describe('Messages Routes', () => {
|
||||
it('should create a new message', async () => {
|
||||
const messageData = {
|
||||
receiverId: 2,
|
||||
subject: 'New Message',
|
||||
content: 'Hello Jane!',
|
||||
parentMessageId: null
|
||||
content: 'Hello Jane!'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
@@ -378,31 +329,8 @@ describe('Messages Routes', () => {
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'New Message',
|
||||
content: 'Hello Jane!',
|
||||
parentMessageId: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a reply message with parentMessageId', async () => {
|
||||
const replyData = {
|
||||
receiverId: 2,
|
||||
subject: 'Re: Original Message',
|
||||
content: 'This is a reply',
|
||||
parentMessageId: 1
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send(replyData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: 'Re: Original Message',
|
||||
content: 'This is a reply',
|
||||
parentMessageId: 1
|
||||
imagePath: null
|
||||
});
|
||||
});
|
||||
|
||||
@@ -413,7 +341,6 @@ describe('Messages Routes', () => {
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 999,
|
||||
subject: 'Test',
|
||||
content: 'Test message'
|
||||
});
|
||||
|
||||
@@ -426,7 +353,6 @@ describe('Messages Routes', () => {
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 1, // Same as sender ID
|
||||
subject: 'Self Message',
|
||||
content: 'Hello self!'
|
||||
});
|
||||
|
||||
@@ -441,7 +367,6 @@ describe('Messages Routes', () => {
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 2,
|
||||
subject: 'Test',
|
||||
content: 'Test message'
|
||||
});
|
||||
|
||||
@@ -596,62 +521,5 @@ describe('Messages Routes', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle message with no replies', async () => {
|
||||
const messageWithoutReplies = {
|
||||
id: 1,
|
||||
senderId: 2,
|
||||
receiverId: 1,
|
||||
subject: 'Test Message',
|
||||
content: 'Hello there!',
|
||||
isRead: false,
|
||||
replies: [],
|
||||
update: jest.fn()
|
||||
};
|
||||
mockMessageFindOne.mockResolvedValue(messageWithoutReplies);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/messages/1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.replies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields in message creation', async () => {
|
||||
const mockReceiver = { id: 2, firstName: 'Jane', lastName: 'Smith' };
|
||||
const mockCreatedMessage = {
|
||||
id: 6,
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: undefined,
|
||||
content: 'Just content',
|
||||
parentMessageId: undefined
|
||||
};
|
||||
const mockMessageWithSender = {
|
||||
...mockCreatedMessage,
|
||||
sender: { id: 1, firstName: 'John', lastName: 'Doe' }
|
||||
};
|
||||
|
||||
mockUserFindByPk.mockResolvedValue(mockReceiver);
|
||||
mockMessageCreate.mockResolvedValue(mockCreatedMessage);
|
||||
mockMessageFindByPk.mockResolvedValue(mockMessageWithSender);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/messages')
|
||||
.send({
|
||||
receiverId: 2,
|
||||
content: 'Just content'
|
||||
// subject and parentMessageId omitted
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockMessageCreate).toHaveBeenCalledWith({
|
||||
senderId: 1,
|
||||
receiverId: 2,
|
||||
subject: undefined,
|
||||
content: 'Just content',
|
||||
parentMessageId: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ import Owning from './pages/Owning';
|
||||
import Profile from './pages/Profile';
|
||||
import PublicProfile from './pages/PublicProfile';
|
||||
import Messages from './pages/Messages';
|
||||
import MessageDetail from './pages/MessageDetail';
|
||||
import ForumPosts from './pages/ForumPosts';
|
||||
import ForumPostDetail from './pages/ForumPostDetail';
|
||||
import CreateForumPost from './pages/CreateForumPost';
|
||||
@@ -150,14 +149,6 @@ const AppContent: React.FC = () => {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/messages/:id"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<MessageDetail />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/forum" element={<ForumPosts />} />
|
||||
<Route path="/forum/:id" element={<ForumPostDetail />} />
|
||||
<Route
|
||||
|
||||
@@ -322,7 +322,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
||||
// Build FormData for message (with or without image)
|
||||
const formData = new FormData();
|
||||
formData.append('receiverId', recipient.id);
|
||||
formData.append('subject', `Message from ${currentUser?.firstName}`);
|
||||
formData.append('content', messageContent || ' '); // Send space if only image
|
||||
if (imageToSend) {
|
||||
formData.append('image', imageToSend);
|
||||
|
||||
@@ -10,7 +10,6 @@ interface MessageModalProps {
|
||||
}
|
||||
|
||||
const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, onSuccess }) => {
|
||||
const [subject, setSubject] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -23,12 +22,10 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('receiverId', recipient.id);
|
||||
formData.append('subject', subject);
|
||||
formData.append('content', content);
|
||||
|
||||
await messageAPI.sendMessage(formData);
|
||||
|
||||
setSubject('');
|
||||
setContent('');
|
||||
onClose();
|
||||
if (onSuccess) {
|
||||
@@ -59,19 +56,6 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="subject" className="form-label">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="content" className="form-label">Message</label>
|
||||
<textarea
|
||||
@@ -98,7 +82,7 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={sending || !subject || !content}
|
||||
disabled={sending || !content}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Message } from '../types';
|
||||
import { messageAPI } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
|
||||
const MessageDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { isConnected, onNewMessage } = useSocket();
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessage();
|
||||
}, [id]);
|
||||
|
||||
// Listen for new replies in real-time
|
||||
useEffect(() => {
|
||||
if (!isConnected || !message) return;
|
||||
|
||||
const cleanup = onNewMessage((newMessage: Message) => {
|
||||
// Check if this is a reply to the current thread
|
||||
if (newMessage.parentMessageId === message.id) {
|
||||
setMessage((prevMessage) => {
|
||||
if (!prevMessage) return prevMessage;
|
||||
|
||||
// Check if reply already exists (avoid duplicates)
|
||||
const replies = prevMessage.replies || [];
|
||||
if (replies.some(r => r.id === newMessage.id)) {
|
||||
return prevMessage;
|
||||
}
|
||||
|
||||
// Add new reply to the thread
|
||||
return {
|
||||
...prevMessage,
|
||||
replies: [...replies, newMessage]
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [isConnected, message?.id, onNewMessage]);
|
||||
|
||||
const fetchMessage = async () => {
|
||||
try {
|
||||
const response = await messageAPI.getMessage(id!);
|
||||
setMessage(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch message');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message) return;
|
||||
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('receiverId', recipientId);
|
||||
formData.append('subject', `Re: ${message.subject}`);
|
||||
formData.append('content', replyContent);
|
||||
formData.append('parentMessageId', message.id);
|
||||
|
||||
const response = await messageAPI.sendMessage(formData);
|
||||
|
||||
setReplyContent('');
|
||||
|
||||
// Note: Socket will automatically add the reply to the thread
|
||||
// But we add it manually for immediate feedback if socket is disconnected
|
||||
if (!isConnected) {
|
||||
setMessage((prevMessage) => {
|
||||
if (!prevMessage) return prevMessage;
|
||||
const replies = prevMessage.replies || [];
|
||||
return {
|
||||
...prevMessage,
|
||||
replies: [...replies, response.data]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
alert('Reply sent successfully!');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to send reply');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
Message not found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isReceiver = message.receiverId === user?.id;
|
||||
const otherUser = isReceiver ? message.sender : message.receiver;
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<button
|
||||
className="btn btn-link text-decoration-none mb-3"
|
||||
onClick={() => navigate('/messages')}
|
||||
>
|
||||
<i className="bi bi-arrow-left"></i> Back to Messages
|
||||
</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex align-items-center">
|
||||
{otherUser?.profileImage ? (
|
||||
<img
|
||||
src={otherUser.profileImage}
|
||||
alt={`${otherUser.firstName} ${otherUser.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>
|
||||
<h5 className="mb-0">{message.subject}</h5>
|
||||
<small className="text-muted">
|
||||
{isReceiver ? 'From' : 'To'}: {otherUser?.firstName} {otherUser?.lastName} • {formatDateTime(message.createdAt)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p style={{ whiteSpace: 'pre-wrap' }}>{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.replies && message.replies.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h6>Replies</h6>
|
||||
{message.replies.map((reply) => (
|
||||
<div key={reply.id} className="card mb-2">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
{reply.sender?.profileImage ? (
|
||||
<img
|
||||
src={reply.sender.profileImage}
|
||||
alt={`${reply.sender.firstName} ${reply.sender.lastName}`}
|
||||
className="rounded-circle me-2"
|
||||
style={{ width: '30px', height: '30px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-2"
|
||||
style={{ width: '30px', height: '30px' }}
|
||||
>
|
||||
<i className="bi bi-person-fill text-white" style={{ fontSize: '0.8rem' }}></i>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>{reply.sender?.firstName} {reply.sender?.lastName}</strong>
|
||||
<small className="text-muted ms-2">{formatDateTime(reply.createdAt)}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-0" style={{ whiteSpace: 'pre-wrap' }}>{reply.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mt-4">
|
||||
<div className="card-body">
|
||||
<h6>Send Reply</h6>
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleReply}>
|
||||
<div className="mb-3">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={4}
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder="Type your reply..."
|
||||
required
|
||||
disabled={sending}
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={sending || !replyContent.trim()}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-send-fill me-2"></i>Send Reply
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageDetail;
|
||||
@@ -39,14 +39,11 @@ export interface Message {
|
||||
id: string;
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
isRead: boolean;
|
||||
parentMessageId?: string;
|
||||
imagePath?: string;
|
||||
sender?: User;
|
||||
receiver?: User;
|
||||
replies?: Message[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user