can add image to message
This commit is contained in:
@@ -35,6 +35,29 @@ const uploadProfileImage = multer({
|
||||
}
|
||||
}).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 = {
|
||||
uploadProfileImage
|
||||
uploadProfileImage,
|
||||
uploadMessageImage
|
||||
};
|
||||
@@ -42,6 +42,10 @@ const Message = sequelize.define('Message', {
|
||||
model: 'Messages',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
imagePath: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const { Message, User } = require('../models');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { uploadMessageImage } = require('../middleware/upload');
|
||||
const logger = require('../utils/logger');
|
||||
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
|
||||
const { Op } = require('sequelize');
|
||||
const emailService = require('../services/emailService');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all messages for the current user (inbox)
|
||||
@@ -242,7 +246,7 @@ router.get('/:id', authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
// Send a new message
|
||||
router.post('/', authenticateToken, async (req, res) => {
|
||||
router.post('/', authenticateToken, uploadMessageImage, async (req, res) => {
|
||||
try {
|
||||
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' });
|
||||
}
|
||||
|
||||
// Extract image filename if uploaded
|
||||
const imagePath = req.file ? req.file.filename : null;
|
||||
|
||||
const message = await Message.create({
|
||||
senderId: req.user.id,
|
||||
receiverId,
|
||||
subject,
|
||||
content,
|
||||
parentMessageId
|
||||
parentMessageId,
|
||||
imagePath
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useAuth } from '../contexts/AuthContext';
|
||||
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 [isAtBottom, setIsAtBottom] = useState(true);
|
||||
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 messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
@@ -258,9 +261,47 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
if (!newMessage.trim() && !selectedImage) return;
|
||||
|
||||
// Stop typing indicator
|
||||
if (isConnected) {
|
||||
@@ -269,14 +310,25 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
||||
|
||||
setSending(true);
|
||||
const messageContent = newMessage;
|
||||
const imageToSend = selectedImage;
|
||||
setNewMessage(''); // Clear input immediately for better UX
|
||||
setSelectedImage(null);
|
||||
setImagePreview(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await messageAPI.sendMessage({
|
||||
receiverId: recipient.id,
|
||||
subject: `Message from ${currentUser?.firstName}`,
|
||||
content: messageContent
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
|
||||
const response = await messageAPI.sendMessage(formData);
|
||||
|
||||
// Add message to sender's chat immediately for instant feedback
|
||||
// Socket will handle updating the receiver's chat
|
||||
@@ -290,6 +342,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', 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 {
|
||||
setSending(false);
|
||||
// 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'
|
||||
}}
|
||||
>
|
||||
{message.imagePath && (
|
||||
<div className="mb-2">
|
||||
<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
|
||||
className={isCurrentUser ? 'opacity-75' : 'text-muted'}
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
@@ -452,7 +530,46 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
||||
|
||||
{/* Input Area */}
|
||||
<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">
|
||||
<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
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -465,7 +582,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={sending || !newMessage.trim()}
|
||||
disabled={sending || (!newMessage.trim() && !selectedImage)}
|
||||
>
|
||||
{sending ? (
|
||||
<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);
|
||||
|
||||
try {
|
||||
await messageAPI.sendMessage({
|
||||
receiverId: recipient.id,
|
||||
subject,
|
||||
content
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append('receiverId', recipient.id);
|
||||
formData.append('subject', subject);
|
||||
formData.append('content', content);
|
||||
|
||||
await messageAPI.sendMessage(formData);
|
||||
|
||||
setSubject('');
|
||||
setContent('');
|
||||
|
||||
@@ -68,12 +68,14 @@ const MessageDetail: React.FC = () => {
|
||||
|
||||
try {
|
||||
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
|
||||
const response = await messageAPI.sendMessage({
|
||||
receiverId: recipientId,
|
||||
subject: `Re: ${message.subject}`,
|
||||
content: replyContent,
|
||||
parentMessageId: message.id
|
||||
});
|
||||
|
||||
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('');
|
||||
|
||||
|
||||
@@ -164,9 +164,12 @@ export const authAPI = {
|
||||
getStatus: () => api.get("/auth/status"),
|
||||
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
|
||||
resendVerification: () => api.post("/auth/resend-verification"),
|
||||
forgotPassword: (email: string) => api.post("/auth/forgot-password", { email }),
|
||||
verifyResetToken: (token: string) => api.post("/auth/verify-reset-token", { token }),
|
||||
resetPassword: (token: string, newPassword: string) => api.post("/auth/reset-password", { token, newPassword }),
|
||||
forgotPassword: (email: string) =>
|
||||
api.post("/auth/forgot-password", { email }),
|
||||
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 = {
|
||||
@@ -241,7 +244,12 @@ export const messageAPI = {
|
||||
getSentMessages: () => api.get("/messages/sent"),
|
||||
getConversations: () => api.get("/messages/conversations"),
|
||||
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`),
|
||||
getUnreadCount: () => api.get("/messages/unread/count"),
|
||||
};
|
||||
@@ -304,4 +312,8 @@ export const feedbackAPI = {
|
||||
api.post("/feedback", data),
|
||||
};
|
||||
|
||||
// Helper to construct message image URLs
|
||||
export const getMessageImageUrl = (imagePath: string) =>
|
||||
`${API_BASE_URL}/messages/images/${imagePath}`;
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Message {
|
||||
content: string;
|
||||
isRead: boolean;
|
||||
parentMessageId?: string;
|
||||
imagePath?: string;
|
||||
sender?: User;
|
||||
receiver?: User;
|
||||
replies?: Message[];
|
||||
|
||||
Reference in New Issue
Block a user