From 95757dd169489ab6ce29d963ba8b76bc9b68e063 Mon Sep 17 00:00:00 2001 From: Gal Podlipnik Date: Thu, 12 Jun 2025 15:54:34 +0200 Subject: [PATCH] images --- .../20250612134204_add_images/migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/src/services/messageService.ts | 27 +++--- backend/src/socket/socketHandlers.ts | 15 ++++ backend/src/types/index.ts | 1 + frontend/src/components/MessageCard.tsx | 9 ++ frontend/src/components/MessageInput.tsx | 82 +++++++++++++++++-- frontend/src/components/MessageList.tsx | 40 ++++++++- frontend/src/types/index.ts | 1 + 9 files changed, 157 insertions(+), 21 deletions(-) create mode 100644 backend/prisma/migrations/20250612134204_add_images/migration.sql diff --git a/backend/prisma/migrations/20250612134204_add_images/migration.sql b/backend/prisma/migrations/20250612134204_add_images/migration.sql new file mode 100644 index 0000000..8aa8fa8 --- /dev/null +++ b/backend/prisma/migrations/20250612134204_add_images/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "messages" ADD COLUMN "image" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 97cc71d..4b7dbb8 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -61,6 +61,7 @@ model ChatRoomMember { model Message { id String @id @default(cuid()) content String + image String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/services/messageService.ts b/backend/src/services/messageService.ts index 7521b88..0a5f95d 100644 --- a/backend/src/services/messageService.ts +++ b/backend/src/services/messageService.ts @@ -7,6 +7,7 @@ export class MessageService { const message = await prisma.message.create({ data: { content: data.content, + image: data.image, userId, roomId: data.roomId, }, @@ -109,19 +110,19 @@ export class MessageService { static async markMessagesAsSeen(userId: string, messageIds: string[]): Promise { try { - const seenEntries = messageIds.map(messageId => ({ + const seenEntries = messageIds.map((messageId) => ({ userId, - messageId + messageId, })); await prisma.messageSeen.createMany({ data: seenEntries, skipDuplicates: true, - }) + }); const messages = await prisma.message.findMany({ where: { - id: { in: messageIds } + id: { in: messageIds }, }, include: { seenBy: { @@ -130,22 +131,22 @@ export class MessageService { select: { id: true, username: true, - } - } - } - } - } - }) + }, + }, + }, + }, + }, + }); - const seenData = messages.map(message => ({ + const seenData = messages.map((message) => ({ messageId: message.id, - seenBy: message.seenBy.map(seen => seen.userId) + seenBy: message.seenBy.map((seen) => seen.userId), })); return { success: true, data: seenData, - } + }; } catch (error) { console.error("Mark messages as seen error:", error); return { diff --git a/backend/src/socket/socketHandlers.ts b/backend/src/socket/socketHandlers.ts index 4a95933..5df8586 100644 --- a/backend/src/socket/socketHandlers.ts +++ b/backend/src/socket/socketHandlers.ts @@ -97,6 +97,21 @@ const joinUserRooms = async (socket: AuthenticatedSocket) => { const handleSendMessage = async (io: Server, socket: AuthenticatedSocket, data: SendMessageRequest) => { try { + if (!data.roomId) { + socket.emit("error", { message: "Room ID is required to send a message" }); + return; + } + + if (data.image && data.image.length > 2 * 1024 * 1024) { + socket.emit("error", { message: "Image size exceeds 2MB limit" }); + return; + } + + if (!data.content && !data.image) { + socket.emit("error", { message: "Message cannot be empty" }); + return; + } + if (!checkMessageRateLimit(socket)) return; const isMember = await ChatService.checkRoomMembership(socket.userId, data.roomId); diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 5568a5d..517bb89 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -24,6 +24,7 @@ export type AuthenticatedSocket = Socket & { export type SendMessageRequest = { roomId: string; content: string; + image?: string; }; export type ReactToMessageRequest = { diff --git a/frontend/src/components/MessageCard.tsx b/frontend/src/components/MessageCard.tsx index a8aa8bc..bb93183 100644 --- a/frontend/src/components/MessageCard.tsx +++ b/frontend/src/components/MessageCard.tsx @@ -104,6 +104,15 @@ export const MessageCard: FC<{ message: Message }> = ({ message }) => { onTouchStart={handleMouseDown} onTouchEnd={handleMouseUp} > + {message.image && ( +
+ Message attachment +
+ )}
{message.content}
diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index f760a0a..cd43eaa 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -1,6 +1,6 @@ import { useSocket } from '@/hooks/useSocket'; import { useAuthStore } from '@/stores/authStore'; -import { Mic, Paperclip, Send, Smile } from 'lucide-react'; +import { Image, Mic, Send, Smile, X } from 'lucide-react'; import { useEffect, useRef, @@ -23,9 +23,11 @@ export const MessageInput: FC = ({ roomId }) => { const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [isFocused, setIsFocused] = useState(false); const [isTyping, setIsTyping] = useState(false); + const [imagePreview, setImagePreview] = useState(null); const { socket } = useSocket(); const { user } = useAuthStore(); const inputRef = useRef(null); + const imageInputRef = useRef(null); const typingTimeoutRef = useRef(null); useEffect(() => { @@ -54,11 +56,12 @@ export const MessageInput: FC = ({ roomId }) => { const handleSubmit = (e: FormEvent) => { e.preventDefault(); - if (!message.trim() || !socket || isRateLimited) return; + if ((!message.trim() && !imagePreview) || !socket || isRateLimited) return; socket.emit('send_message', { roomId, content: message.trim(), + image: imagePreview, }); if (isTyping && user) { @@ -67,6 +70,11 @@ export const MessageInput: FC = ({ roomId }) => { } setMessage(''); + setImagePreview(null); + + if (imageInputRef.current) { + imageInputRef.current.value = ''; + } }; const handleEmojiClick = (emoji: string) => { @@ -100,6 +108,34 @@ export const MessageInput: FC = ({ roomId }) => { handleTypingStart(); }; + const handleFileSelect = () => { + imageInputRef.current?.click(); + }; + + const handleFileChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 1024 * 1024 * 2) { + toast.error('Image too large. Please select an image under 2MB.'); + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const base64 = event.target?.result as string; + setImagePreview(base64); + }; + reader.readAsDataURL(file); + }; + + const handleRemoveImage = () => { + setImagePreview(null); + if (imageInputRef.current) { + imageInputRef.current.value = ''; + } + }; + useEffect(() => { return () => { if (typingTimeoutRef.current) { @@ -115,6 +151,27 @@ export const MessageInput: FC = ({ roomId }) => { return (
+ {imagePreview && ( +
+
+ Image preview + +
+
+ )} +
= ({ roomId }) => { variant="ghost" size="icon" className="rounded-full h-9 w-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700" + onClick={handleFileSelect} > - + + + = ({ roomId }) => { placeholder={ isRateLimited ? 'Rate limited - please wait...' - : 'Type your message...' + : imagePreview + ? 'Add a caption...' + : 'Type your message...' } disabled={isRateLimited} className="flex-1 border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent py-2 px-3" /> - {message.trim() ? ( + {message.trim() || imagePreview ? (
- {/* Emoji picker placeholder - you'd need to implement or use a library like emoji-mart */} + {/* Emoji picker TODO: implement emoji-mart */} {showEmojiPicker && (
diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 8507249..0da8870 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -4,7 +4,7 @@ import { useChatStore } from '@/stores/chatStore'; import { useSocketStore } from '@/stores/socketStore'; import type { Message } from '@/types'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useRef, type FC } from 'react'; +import { useEffect, useRef, useState, type FC } from 'react'; import { MessageCard } from './MessageCard'; import { TypingIndicator } from './TypingIndicator'; import { ScrollArea } from './ui/scroll-area'; @@ -17,6 +17,7 @@ export const MessageList: FC = ({ roomId }) => { const { messages, setMessages } = useChatStore(); const { socket } = useSocketStore(); const { user } = useAuthStore(); + const [lastMessageId, setLastMessageId] = useState(null); const messagesEndRef = useRef(null); const scrollAreaRef = useRef(null); @@ -43,6 +44,43 @@ export const MessageList: FC = ({ roomId }) => { } }, [isLoading, roomId]); + useEffect(() => { + if (roomMessages.length > 0) { + const currentLastMessageId = roomMessages[roomMessages.length - 1].id; + + if (currentLastMessageId !== lastMessageId) { + setLastMessageId(currentLastMessageId); + + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 50); + } + } + }, [roomMessages, lastMessageId]); + + useEffect(() => { + const handleImageLoad = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const container = scrollAreaRef.current; + if (container) { + const images = container.querySelectorAll('img'); + images.forEach((img) => { + if (!img.complete) { + img.addEventListener('load', handleImageLoad); + } + }); + + return () => { + const images = container.querySelectorAll('img'); + images.forEach((img) => { + img.removeEventListener('load', handleImageLoad); + }); + }; + } + }, [roomMessages]); + useEffect(() => { if (!socket || !user || !roomId || roomMessages.length === 0) return; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1a8cf27..0ae1947 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -31,6 +31,7 @@ export type ChatRoomMember = { export type Message = { id: string; content: string; + image?: string; createdAt: string; userId: string; roomId: string;