can add image to message

This commit is contained in:
jackiettran
2025-11-10 22:45:29 -05:00
parent d8a927ac4e
commit 4a4eee86a7
8 changed files with 251 additions and 36 deletions

View File

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

View File

@@ -42,6 +42,10 @@ const Message = sequelize.define('Message', {
model: 'Messages',
key: 'id'
}
},
imagePath: {
type: DataTypes.STRING,
allowNull: true
}
}, {
timestamps: true

View File

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

View File

@@ -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
@@ -413,21 +473,39 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
</div>
)}
<div className={`d-flex mb-2 ${isCurrentUser ? 'justify-content-end' : ''}`}>
<div
<div
className={`px-3 py-2 rounded-3 ${
isCurrentUser
? 'bg-primary text-white'
isCurrentUser
? 'bg-primary text-white'
: 'bg-white border'
}`}
style={{
style={{
maxWidth: '75%',
wordBreak: 'break-word'
}}
>
<p className="mb-1" style={{ fontSize: '0.95rem' }}>
{message.content}
</p>
<small
{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"
@@ -462,10 +579,10 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMes
onChange={handleInputChange}
disabled={sending}
/>
<button
className="btn btn-primary"
<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>

View File

@@ -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('');

View File

@@ -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('');

View File

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

View File

@@ -41,6 +41,7 @@ export interface Message {
content: string;
isRead: boolean;
parentMessageId?: string;
imagePath?: string;
sender?: User;
receiver?: User;
replies?: Message[];