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

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