simplified message model

This commit is contained in:
jackiettran
2025-11-25 17:22:57 -05:00
parent 2983f67ce8
commit 31d94b1b3f
12 changed files with 74 additions and 473 deletions

View 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");
},
};

View File

@@ -23,10 +23,6 @@ const Message = sequelize.define('Message', {
key: 'id' key: 'id'
} }
}, },
subject: {
type: DataTypes.STRING,
allowNull: false
},
content: { content: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false allowNull: false
@@ -35,14 +31,6 @@ const Message = sequelize.define('Message', {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false defaultValue: false
}, },
parentMessageId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'Messages',
key: 'id'
}
},
imagePath: { imagePath: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true allowNull: true

View File

@@ -27,11 +27,6 @@ User.hasMany(Message, { as: "sentMessages", foreignKey: "senderId" });
User.hasMany(Message, { as: "receivedMessages", foreignKey: "receiverId" }); User.hasMany(Message, { as: "receivedMessages", foreignKey: "receiverId" });
Message.belongsTo(User, { as: "sender", foreignKey: "senderId" }); Message.belongsTo(User, { as: "sender", foreignKey: "senderId" });
Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" }); Message.belongsTo(User, { as: "receiver", foreignKey: "receiverId" });
Message.hasMany(Message, { as: "replies", foreignKey: "parentMessageId" });
Message.belongsTo(Message, {
as: "parentMessage",
foreignKey: "parentMessageId",
});
// Forum associations // Forum associations
User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" }); User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" });

View File

@@ -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) => { router.get('/:id', authenticateToken, async (req, res) => {
try { try {
const message = await Message.findOne({ const message = await Message.findOne({
@@ -192,15 +192,6 @@ router.get('/:id', authenticateToken, async (req, res) => {
model: User, model: User,
as: 'receiver', as: 'receiver',
attributes: ['id', 'firstName', 'lastName', 'profileImage'] 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 // Send a new message
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => { router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
try { try {
const { receiverId, subject, content, parentMessageId } = req.body; const { receiverId, content } = req.body;
// Check if receiver exists // Check if receiver exists
const receiver = await User.findByPk(receiverId); const receiver = await User.findByPk(receiverId);
@@ -267,9 +258,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
const message = await Message.create({ const message = await Message.create({
senderId: req.user.id, senderId: req.user.id,
receiverId, receiverId,
subject,
content, content,
parentMessageId,
imagePath imagePath
}); });
@@ -308,8 +297,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
reqLogger.info("Message sent", { reqLogger.info("Message sent", {
senderId: req.user.id, senderId: req.user.id,
receiverId: receiverId, receiverId: receiverId,
messageId: message.id, messageId: message.id
isReply: !!parentMessageId
}); });
res.status(201).json(messageWithSender); res.status(201).json(messageWithSender);

View File

@@ -39,7 +39,6 @@ class MessagingEmailService {
* @param {string} sender.firstName - Sender's first name * @param {string} sender.firstName - Sender's first name
* @param {string} sender.lastName - Sender's last name * @param {string} sender.lastName - Sender's last name
* @param {Object} message - Message object * @param {Object} message - Message object
* @param {string} message.subject - Message subject
* @param {string} message.content - Message content * @param {string} message.content - Message content
* @param {Date} message.createdAt - Message creation timestamp * @param {Date} message.createdAt - Message creation timestamp
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>} * @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
@@ -61,7 +60,6 @@ class MessagingEmailService {
const variables = { const variables = {
recipientName: receiver.firstName || "there", recipientName: receiver.firstName || "there",
senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user", senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user",
subject: message.subject,
messageContent: message.content, messageContent: message.content,
conversationUrl: conversationUrl, conversationUrl: conversationUrl,
timestamp: timestamp, timestamp: timestamp,

View File

@@ -134,13 +134,6 @@
border-radius: 6px; border-radius: 6px;
} }
.message-box .subject {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0 0 15px 0;
}
.message-box .content-text { .message-box .content-text {
color: #212529; color: #212529;
line-height: 1.6; line-height: 1.6;
@@ -226,7 +219,6 @@
<p>{{senderName}} sent you a message on RentAll.</p> <p>{{senderName}} sent you a message on RentAll.</p>
<div class="message-box"> <div class="message-box">
<div class="subject">Subject: {{subject}}</div>
<div class="content-text">{{messageContent}}</div> <div class="content-text">{{messageContent}}</div>
<div class="timestamp">Sent {{timestamp}}</div> <div class="timestamp">Sent {{timestamp}}</div>
</div> </div>

View File

@@ -56,7 +56,6 @@ describe('Messages Routes', () => {
id: 1, id: 1,
senderId: 2, senderId: 2,
receiverId: 1, receiverId: 1,
subject: 'Test Message',
content: 'Hello there!', content: 'Hello there!',
isRead: false, isRead: false,
createdAt: '2024-01-15T10:00:00.000Z', createdAt: '2024-01-15T10:00:00.000Z',
@@ -71,7 +70,6 @@ describe('Messages Routes', () => {
id: 2, id: 2,
senderId: 3, senderId: 3,
receiverId: 1, receiverId: 1,
subject: 'Another Message',
content: 'Hi!', content: 'Hi!',
isRead: true, isRead: true,
createdAt: '2024-01-14T10:00:00.000Z', createdAt: '2024-01-14T10:00:00.000Z',
@@ -122,7 +120,6 @@ describe('Messages Routes', () => {
id: 3, id: 3,
senderId: 1, senderId: 1,
receiverId: 2, receiverId: 2,
subject: 'My Message',
content: 'Hello Jane!', content: 'Hello Jane!',
isRead: false, isRead: false,
createdAt: '2024-01-15T12:00:00.000Z', createdAt: '2024-01-15T12:00:00.000Z',
@@ -171,7 +168,6 @@ describe('Messages Routes', () => {
id: 1, id: 1,
senderId: 2, senderId: 2,
receiverId: 1, receiverId: 1,
subject: 'Test Message',
content: 'Hello there!', content: 'Hello there!',
isRead: false, isRead: false,
createdAt: '2024-01-15T10:00:00.000Z', createdAt: '2024-01-15T10:00:00.000Z',
@@ -187,19 +183,6 @@ describe('Messages Routes', () => {
lastName: 'Doe', lastName: 'Doe',
profileImage: 'john.jpg' profileImage: 'john.jpg'
}, },
replies: [
{
id: 4,
senderId: 1,
content: 'Reply message',
sender: {
id: 1,
firstName: 'John',
lastName: 'Doe',
profileImage: 'john.jpg'
}
}
],
update: jest.fn() update: jest.fn()
}; };
@@ -207,7 +190,7 @@ describe('Messages Routes', () => {
mockMessageFindOne.mockResolvedValue(mockMessage); 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(); mockMessage.update.mockResolvedValue();
const response = await request(app) const response = await request(app)
@@ -218,7 +201,6 @@ describe('Messages Routes', () => {
id: 1, id: 1,
senderId: 2, senderId: 2,
receiverId: 1, receiverId: 1,
subject: 'Test Message',
content: 'Hello there!', content: 'Hello there!',
isRead: false, isRead: false,
createdAt: '2024-01-15T10:00:00.000Z', createdAt: '2024-01-15T10:00:00.000Z',
@@ -233,20 +215,7 @@ describe('Messages Routes', () => {
firstName: 'John', firstName: 'John',
lastName: 'Doe', lastName: 'Doe',
profileImage: 'john.jpg' 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 }); expect(mockMessage.update).toHaveBeenCalledWith({ isRead: true });
}); });
@@ -263,7 +232,6 @@ describe('Messages Routes', () => {
id: 1, id: 1,
senderId: 1, senderId: 1,
receiverId: 2, receiverId: 2,
subject: 'Test Message',
content: 'Hello there!', content: 'Hello there!',
isRead: false, isRead: false,
createdAt: '2024-01-15T10:00:00.000Z', createdAt: '2024-01-15T10:00:00.000Z',
@@ -278,20 +246,7 @@ describe('Messages Routes', () => {
firstName: 'John', firstName: 'John',
lastName: 'Doe', lastName: 'Doe',
profileImage: 'john.jpg' 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(); expect(mockMessage.update).not.toHaveBeenCalled();
}); });
@@ -340,9 +295,7 @@ describe('Messages Routes', () => {
id: 5, id: 5,
senderId: 1, senderId: 1,
receiverId: 2, receiverId: 2,
subject: 'New Message', content: 'Hello Jane!'
content: 'Hello Jane!',
parentMessageId: null
}; };
const mockMessageWithSender = { const mockMessageWithSender = {
@@ -364,9 +317,7 @@ describe('Messages Routes', () => {
it('should create a new message', async () => { it('should create a new message', async () => {
const messageData = { const messageData = {
receiverId: 2, receiverId: 2,
subject: 'New Message', content: 'Hello Jane!'
content: 'Hello Jane!',
parentMessageId: null
}; };
const response = await request(app) const response = await request(app)
@@ -378,31 +329,8 @@ describe('Messages Routes', () => {
expect(mockMessageCreate).toHaveBeenCalledWith({ expect(mockMessageCreate).toHaveBeenCalledWith({
senderId: 1, senderId: 1,
receiverId: 2, receiverId: 2,
subject: 'New Message',
content: 'Hello Jane!', content: 'Hello Jane!',
parentMessageId: null imagePath: 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
}); });
}); });
@@ -413,7 +341,6 @@ describe('Messages Routes', () => {
.post('/messages') .post('/messages')
.send({ .send({
receiverId: 999, receiverId: 999,
subject: 'Test',
content: 'Test message' content: 'Test message'
}); });
@@ -426,7 +353,6 @@ describe('Messages Routes', () => {
.post('/messages') .post('/messages')
.send({ .send({
receiverId: 1, // Same as sender ID receiverId: 1, // Same as sender ID
subject: 'Self Message',
content: 'Hello self!' content: 'Hello self!'
}); });
@@ -441,7 +367,6 @@ describe('Messages Routes', () => {
.post('/messages') .post('/messages')
.send({ .send({
receiverId: 2, receiverId: 2,
subject: 'Test',
content: 'Test message' content: 'Test message'
}); });
@@ -596,62 +521,5 @@ describe('Messages Routes', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual([]); 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
});
});
}); });
}); });

View File

@@ -21,7 +21,6 @@ import Owning from './pages/Owning';
import Profile from './pages/Profile'; import Profile from './pages/Profile';
import PublicProfile from './pages/PublicProfile'; import PublicProfile from './pages/PublicProfile';
import Messages from './pages/Messages'; import Messages from './pages/Messages';
import MessageDetail from './pages/MessageDetail';
import ForumPosts from './pages/ForumPosts'; import ForumPosts from './pages/ForumPosts';
import ForumPostDetail from './pages/ForumPostDetail'; import ForumPostDetail from './pages/ForumPostDetail';
import CreateForumPost from './pages/CreateForumPost'; import CreateForumPost from './pages/CreateForumPost';
@@ -150,14 +149,6 @@ const AppContent: React.FC = () => {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/messages/:id"
element={
<PrivateRoute>
<MessageDetail />
</PrivateRoute>
}
/>
<Route path="/forum" element={<ForumPosts />} /> <Route path="/forum" element={<ForumPosts />} />
<Route path="/forum/:id" element={<ForumPostDetail />} /> <Route path="/forum/:id" element={<ForumPostDetail />} />
<Route <Route

View File

@@ -322,7 +322,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
// Build FormData for message (with or without image) // Build FormData for message (with or without image)
const formData = new FormData(); const formData = new FormData();
formData.append('receiverId', recipient.id); formData.append('receiverId', recipient.id);
formData.append('subject', `Message from ${currentUser?.firstName}`);
formData.append('content', messageContent || ' '); // Send space if only image formData.append('content', messageContent || ' '); // Send space if only image
if (imageToSend) { if (imageToSend) {
formData.append('image', imageToSend); formData.append('image', imageToSend);

View File

@@ -10,7 +10,6 @@ interface MessageModalProps {
} }
const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, onSuccess }) => { const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, onSuccess }) => {
const [subject, setSubject] = useState('');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -23,12 +22,10 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('receiverId', recipient.id); formData.append('receiverId', recipient.id);
formData.append('subject', subject);
formData.append('content', content); formData.append('content', content);
await messageAPI.sendMessage(formData); await messageAPI.sendMessage(formData);
setSubject('');
setContent(''); setContent('');
onClose(); onClose();
if (onSuccess) { if (onSuccess) {
@@ -59,19 +56,6 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
</div> </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"> <div className="mb-3">
<label htmlFor="content" className="form-label">Message</label> <label htmlFor="content" className="form-label">Message</label>
<textarea <textarea
@@ -98,7 +82,7 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
<button <button
type="submit" type="submit"
className="btn btn-primary" className="btn btn-primary"
disabled={sending || !subject || !content} disabled={sending || !content}
> >
{sending ? ( {sending ? (
<> <>

View File

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

View File

@@ -39,14 +39,11 @@ export interface Message {
id: string; id: string;
senderId: string; senderId: string;
receiverId: string; receiverId: string;
subject: string;
content: string; content: string;
isRead: boolean; isRead: boolean;
parentMessageId?: string;
imagePath?: string; imagePath?: string;
sender?: User; sender?: User;
receiver?: User; receiver?: User;
replies?: Message[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }