can add image to message
This commit is contained in:
@@ -35,6 +35,29 @@ const uploadProfileImage = multer({
|
|||||||
}
|
}
|
||||||
}).single('profileImage');
|
}).single('profileImage');
|
||||||
|
|
||||||
|
// Configure storage for message images
|
||||||
|
const messageImageStorage = multer.diskStorage({
|
||||||
|
destination: function (req, file, cb) {
|
||||||
|
cb(null, path.join(__dirname, '../uploads/messages'));
|
||||||
|
},
|
||||||
|
filename: function (req, file, cb) {
|
||||||
|
// Generate unique filename: uuid + original extension
|
||||||
|
const uniqueId = uuidv4();
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, `${uniqueId}${ext}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create multer upload middleware for message images
|
||||||
|
const uploadMessageImage = multer({
|
||||||
|
storage: messageImageStorage,
|
||||||
|
fileFilter: imageFileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||||
|
}
|
||||||
|
}).single('image');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
uploadProfileImage
|
uploadProfileImage,
|
||||||
|
uploadMessageImage
|
||||||
};
|
};
|
||||||
@@ -42,6 +42,10 @@ const Message = sequelize.define('Message', {
|
|||||||
model: 'Messages',
|
model: 'Messages',
|
||||||
key: 'id'
|
key: 'id'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
imagePath: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
timestamps: true
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const helmet = require('helmet');
|
||||||
const { Message, User } = require('../models');
|
const { Message, User } = require('../models');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const { uploadMessageImage } = require('../middleware/upload');
|
||||||
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 { Op } = require('sequelize');
|
||||||
const emailService = require('../services/emailService');
|
const emailService = require('../services/emailService');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get all messages for the current user (inbox)
|
// Get all messages for the current user (inbox)
|
||||||
@@ -242,7 +246,7 @@ router.get('/:id', authenticateToken, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send a new message
|
// Send a new message
|
||||||
router.post('/', authenticateToken, async (req, res) => {
|
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { receiverId, subject, content, parentMessageId } = req.body;
|
const { receiverId, subject, content, parentMessageId } = req.body;
|
||||||
|
|
||||||
@@ -257,12 +261,16 @@ router.post('/', authenticateToken, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Cannot send messages to yourself' });
|
return res.status(400).json({ error: 'Cannot send messages to yourself' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract image filename if uploaded
|
||||||
|
const imagePath = req.file ? req.file.filename : null;
|
||||||
|
|
||||||
const message = await Message.create({
|
const message = await Message.create({
|
||||||
senderId: req.user.id,
|
senderId: req.user.id,
|
||||||
receiverId,
|
receiverId,
|
||||||
subject,
|
subject,
|
||||||
content,
|
content,
|
||||||
parentMessageId
|
parentMessageId,
|
||||||
|
imagePath
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageWithSender = await Message.findByPk(message.id, {
|
const messageWithSender = await Message.findByPk(message.id, {
|
||||||
@@ -389,4 +397,51 @@ router.get('/unread/count', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get message image (authorized)
|
||||||
|
router.get('/images/:filename',
|
||||||
|
authenticateToken,
|
||||||
|
// Override Helmet's CORP header for cross-origin image loading
|
||||||
|
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { filename } = req.params;
|
||||||
|
|
||||||
|
// Verify user is sender or receiver of a message with this image
|
||||||
|
const message = await Message.findOne({
|
||||||
|
where: {
|
||||||
|
imagePath: filename,
|
||||||
|
[Op.or]: [
|
||||||
|
{ senderId: req.user.id },
|
||||||
|
{ receiverId: req.user.id }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.warn('Unauthorized image access attempt', {
|
||||||
|
userId: req.user.id,
|
||||||
|
filename
|
||||||
|
});
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve the image
|
||||||
|
const filePath = path.join(__dirname, '../uploads/messages', filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'Image not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendFile(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error('Image serve failed', {
|
||||||
|
error: error.message,
|
||||||
|
filename: req.params.filename
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: 'Failed to load image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||||
import { messageAPI } from '../services/api';
|
import { messageAPI, getMessageImageUrl } from '../services/api';
|
||||||
import { User, Message } from '../types';
|
import { User, Message } from '../types';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useSocket } from '../contexts/SocketContext';
|
import { useSocket } from '../contexts/SocketContext';
|
||||||
@@ -23,11 +23,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
|||||||
const [initialUnreadMessageIds, setInitialUnreadMessageIds] = useState<Set<string>>(new Set());
|
const [initialUnreadMessageIds, setInitialUnreadMessageIds] = useState<Set<string>>(new Set());
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false);
|
const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
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);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (show) {
|
if (show) {
|
||||||
@@ -258,9 +261,47 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
|||||||
handleTyping();
|
handleTyping();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedImage(file);
|
||||||
|
|
||||||
|
// Create preview URL
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImagePreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveImage = () => {
|
||||||
|
setSelectedImage(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageButtonClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async (e: React.FormEvent) => {
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newMessage.trim()) return;
|
if (!newMessage.trim() && !selectedImage) return;
|
||||||
|
|
||||||
// Stop typing indicator
|
// Stop typing indicator
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
@@ -269,14 +310,25 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
|||||||
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
const messageContent = newMessage;
|
const messageContent = newMessage;
|
||||||
|
const imageToSend = selectedImage;
|
||||||
setNewMessage(''); // Clear input immediately for better UX
|
setNewMessage(''); // Clear input immediately for better UX
|
||||||
|
setSelectedImage(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await messageAPI.sendMessage({
|
// Build FormData for message (with or without image)
|
||||||
receiverId: recipient.id,
|
const formData = new FormData();
|
||||||
subject: `Message from ${currentUser?.firstName}`,
|
formData.append('receiverId', recipient.id);
|
||||||
content: messageContent
|
formData.append('subject', `Message from ${currentUser?.firstName}`);
|
||||||
});
|
formData.append('content', messageContent || ' '); // Send space if only image
|
||||||
|
if (imageToSend) {
|
||||||
|
formData.append('image', imageToSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await messageAPI.sendMessage(formData);
|
||||||
|
|
||||||
// Add message to sender's chat immediately for instant feedback
|
// Add message to sender's chat immediately for instant feedback
|
||||||
// Socket will handle updating the receiver's chat
|
// Socket will handle updating the receiver's chat
|
||||||
@@ -290,6 +342,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to send message:', error);
|
||||||
setNewMessage(messageContent); // Restore message on error
|
setNewMessage(messageContent); // Restore message on error
|
||||||
|
setSelectedImage(imageToSend);
|
||||||
|
if (imageToSend) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImagePreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(imageToSend);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
// Defer focus until after all DOM updates and scroll operations complete
|
// Defer focus until after all DOM updates and scroll operations complete
|
||||||
@@ -424,9 +484,27 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
|||||||
wordBreak: 'break-word'
|
wordBreak: 'break-word'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="mb-1" style={{ fontSize: '0.95rem' }}>
|
{message.imagePath && (
|
||||||
{message.content}
|
<div className="mb-2">
|
||||||
</p>
|
<img
|
||||||
|
src={getMessageImageUrl(message.imagePath)}
|
||||||
|
alt="Shared image"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
maxHeight: '300px',
|
||||||
|
objectFit: 'cover'
|
||||||
|
}}
|
||||||
|
onClick={() => window.open(getMessageImageUrl(message.imagePath!), '_blank')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message.content.trim() && (
|
||||||
|
<p className="mb-1" style={{ fontSize: '0.95rem' }}>
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<small
|
<small
|
||||||
className={isCurrentUser ? 'opacity-75' : 'text-muted'}
|
className={isCurrentUser ? 'opacity-75' : 'text-muted'}
|
||||||
style={{ fontSize: '0.75rem' }}
|
style={{ fontSize: '0.75rem' }}
|
||||||
@@ -452,7 +530,46 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
|||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<form onSubmit={handleSend} className="border-top p-3 flex-shrink-0">
|
<form onSubmit={handleSend} className="border-top p-3 flex-shrink-0">
|
||||||
|
{/* Image Preview */}
|
||||||
|
{imagePreview && (
|
||||||
|
<div className="mb-2 position-relative d-inline-block">
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Preview"
|
||||||
|
style={{
|
||||||
|
maxWidth: '150px',
|
||||||
|
maxHeight: '150px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
objectFit: 'cover'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1 rounded-circle"
|
||||||
|
onClick={handleRemoveImage}
|
||||||
|
style={{ width: '24px', height: '24px', padding: '0', fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={handleImageButtonClick}
|
||||||
|
disabled={sending}
|
||||||
|
title="Attach image"
|
||||||
|
>
|
||||||
|
<i className="bi bi-image"></i>
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -465,7 +582,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sending || !newMessage.trim()}
|
disabled={sending || (!newMessage.trim() && !selectedImage)}
|
||||||
>
|
>
|
||||||
{sending ? (
|
{sending ? (
|
||||||
<span className="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
<span className="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ const MessageModal: React.FC<MessageModalProps> = ({ show, onClose, recipient, o
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await messageAPI.sendMessage({
|
const formData = new FormData();
|
||||||
receiverId: recipient.id,
|
formData.append('receiverId', recipient.id);
|
||||||
subject,
|
formData.append('subject', subject);
|
||||||
content
|
formData.append('content', content);
|
||||||
});
|
|
||||||
|
await messageAPI.sendMessage(formData);
|
||||||
|
|
||||||
setSubject('');
|
setSubject('');
|
||||||
setContent('');
|
setContent('');
|
||||||
|
|||||||
@@ -68,12 +68,14 @@ const MessageDetail: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
|
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
|
||||||
const response = await messageAPI.sendMessage({
|
|
||||||
receiverId: recipientId,
|
const formData = new FormData();
|
||||||
subject: `Re: ${message.subject}`,
|
formData.append('receiverId', recipientId);
|
||||||
content: replyContent,
|
formData.append('subject', `Re: ${message.subject}`);
|
||||||
parentMessageId: message.id
|
formData.append('content', replyContent);
|
||||||
});
|
formData.append('parentMessageId', message.id);
|
||||||
|
|
||||||
|
const response = await messageAPI.sendMessage(formData);
|
||||||
|
|
||||||
setReplyContent('');
|
setReplyContent('');
|
||||||
|
|
||||||
|
|||||||
@@ -164,9 +164,12 @@ export const authAPI = {
|
|||||||
getStatus: () => api.get("/auth/status"),
|
getStatus: () => api.get("/auth/status"),
|
||||||
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
|
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
|
||||||
resendVerification: () => api.post("/auth/resend-verification"),
|
resendVerification: () => api.post("/auth/resend-verification"),
|
||||||
forgotPassword: (email: string) => api.post("/auth/forgot-password", { email }),
|
forgotPassword: (email: string) =>
|
||||||
verifyResetToken: (token: string) => api.post("/auth/verify-reset-token", { token }),
|
api.post("/auth/forgot-password", { email }),
|
||||||
resetPassword: (token: string, newPassword: string) => api.post("/auth/reset-password", { token, newPassword }),
|
verifyResetToken: (token: string) =>
|
||||||
|
api.post("/auth/verify-reset-token", { token }),
|
||||||
|
resetPassword: (token: string, newPassword: string) =>
|
||||||
|
api.post("/auth/reset-password", { token, newPassword }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userAPI = {
|
export const userAPI = {
|
||||||
@@ -241,7 +244,12 @@ export const messageAPI = {
|
|||||||
getSentMessages: () => api.get("/messages/sent"),
|
getSentMessages: () => api.get("/messages/sent"),
|
||||||
getConversations: () => api.get("/messages/conversations"),
|
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: (formData: FormData) =>
|
||||||
|
api.post("/messages", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
}),
|
||||||
markAsRead: (id: string) => api.put(`/messages/${id}/read`),
|
markAsRead: (id: string) => api.put(`/messages/${id}/read`),
|
||||||
getUnreadCount: () => api.get("/messages/unread/count"),
|
getUnreadCount: () => api.get("/messages/unread/count"),
|
||||||
};
|
};
|
||||||
@@ -304,4 +312,8 @@ export const feedbackAPI = {
|
|||||||
api.post("/feedback", data),
|
api.post("/feedback", data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to construct message image URLs
|
||||||
|
export const getMessageImageUrl = (imagePath: string) =>
|
||||||
|
`${API_BASE_URL}/messages/images/${imagePath}`;
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface Message {
|
|||||||
content: string;
|
content: string;
|
||||||
isRead: boolean;
|
isRead: boolean;
|
||||||
parentMessageId?: string;
|
parentMessageId?: string;
|
||||||
|
imagePath?: string;
|
||||||
sender?: User;
|
sender?: User;
|
||||||
receiver?: User;
|
receiver?: User;
|
||||||
replies?: Message[];
|
replies?: Message[];
|
||||||
|
|||||||
Reference in New Issue
Block a user