This commit is contained in:
Gal Podlipnik 2025-06-12 15:54:34 +02:00
parent 0342c0d295
commit 95757dd169
9 changed files with 157 additions and 21 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "messages" ADD COLUMN "image" TEXT;

View File

@ -61,6 +61,7 @@ model ChatRoomMember {
model Message { model Message {
id String @id @default(cuid()) id String @id @default(cuid())
content String content String
image String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -7,6 +7,7 @@ export class MessageService {
const message = await prisma.message.create({ const message = await prisma.message.create({
data: { data: {
content: data.content, content: data.content,
image: data.image,
userId, userId,
roomId: data.roomId, roomId: data.roomId,
}, },
@ -109,19 +110,19 @@ export class MessageService {
static async markMessagesAsSeen(userId: string, messageIds: string[]): Promise<ApiResponse> { static async markMessagesAsSeen(userId: string, messageIds: string[]): Promise<ApiResponse> {
try { try {
const seenEntries = messageIds.map(messageId => ({ const seenEntries = messageIds.map((messageId) => ({
userId, userId,
messageId messageId,
})); }));
await prisma.messageSeen.createMany({ await prisma.messageSeen.createMany({
data: seenEntries, data: seenEntries,
skipDuplicates: true, skipDuplicates: true,
}) });
const messages = await prisma.message.findMany({ const messages = await prisma.message.findMany({
where: { where: {
id: { in: messageIds } id: { in: messageIds },
}, },
include: { include: {
seenBy: { seenBy: {
@ -130,22 +131,22 @@ export class MessageService {
select: { select: {
id: true, id: true,
username: true, username: true,
} },
} },
} },
} },
} },
}) });
const seenData = messages.map(message => ({ const seenData = messages.map((message) => ({
messageId: message.id, messageId: message.id,
seenBy: message.seenBy.map(seen => seen.userId) seenBy: message.seenBy.map((seen) => seen.userId),
})); }));
return { return {
success: true, success: true,
data: seenData, data: seenData,
} };
} catch (error) { } catch (error) {
console.error("Mark messages as seen error:", error); console.error("Mark messages as seen error:", error);
return { return {

View File

@ -97,6 +97,21 @@ const joinUserRooms = async (socket: AuthenticatedSocket) => {
const handleSendMessage = async (io: Server, socket: AuthenticatedSocket, data: SendMessageRequest) => { const handleSendMessage = async (io: Server, socket: AuthenticatedSocket, data: SendMessageRequest) => {
try { 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; if (!checkMessageRateLimit(socket)) return;
const isMember = await ChatService.checkRoomMembership(socket.userId, data.roomId); const isMember = await ChatService.checkRoomMembership(socket.userId, data.roomId);

View File

@ -24,6 +24,7 @@ export type AuthenticatedSocket = Socket & {
export type SendMessageRequest = { export type SendMessageRequest = {
roomId: string; roomId: string;
content: string; content: string;
image?: string;
}; };
export type ReactToMessageRequest = { export type ReactToMessageRequest = {

View File

@ -104,6 +104,15 @@ export const MessageCard: FC<{ message: Message }> = ({ message }) => {
onTouchStart={handleMouseDown} onTouchStart={handleMouseDown}
onTouchEnd={handleMouseUp} onTouchEnd={handleMouseUp}
> >
{message.image && (
<div className="mb-2 max-w-xs lg:max-w-md overflow-hidden rounded-lg">
<img
src={message.image}
alt="Message attachment"
className="max-w-full object-contain rounded-lg"
/>
</div>
)}
<div className="break-words max-w-xs lg:max-w-md"> <div className="break-words max-w-xs lg:max-w-md">
{message.content} {message.content}
</div> </div>

View File

@ -1,6 +1,6 @@
import { useSocket } from '@/hooks/useSocket'; import { useSocket } from '@/hooks/useSocket';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import { Mic, Paperclip, Send, Smile } from 'lucide-react'; import { Image, Mic, Send, Smile, X } from 'lucide-react';
import { import {
useEffect, useEffect,
useRef, useRef,
@ -23,9 +23,11 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [isTyping, setIsTyping] = useState(false); const [isTyping, setIsTyping] = useState(false);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const { socket } = useSocket(); const { socket } = useSocket();
const { user } = useAuthStore(); const { user } = useAuthStore();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null); const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
@ -54,11 +56,12 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!message.trim() || !socket || isRateLimited) return; if ((!message.trim() && !imagePreview) || !socket || isRateLimited) return;
socket.emit('send_message', { socket.emit('send_message', {
roomId, roomId,
content: message.trim(), content: message.trim(),
image: imagePreview,
}); });
if (isTyping && user) { if (isTyping && user) {
@ -67,6 +70,11 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
} }
setMessage(''); setMessage('');
setImagePreview(null);
if (imageInputRef.current) {
imageInputRef.current.value = '';
}
}; };
const handleEmojiClick = (emoji: string) => { const handleEmojiClick = (emoji: string) => {
@ -100,6 +108,34 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
handleTypingStart(); handleTypingStart();
}; };
const handleFileSelect = () => {
imageInputRef.current?.click();
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
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(() => { useEffect(() => {
return () => { return () => {
if (typingTimeoutRef.current) { if (typingTimeoutRef.current) {
@ -115,6 +151,27 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
return ( return (
<div className="p-4 border-t border-gray-200 bg-white dark:bg-gray-900 transition-all duration-200"> <div className="p-4 border-t border-gray-200 bg-white dark:bg-gray-900 transition-all duration-200">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{imagePreview && (
<div className="mb-2 p-2 border rounded-lg bg-gray-50 dark:bg-gray-800 relative">
<div className="relative w-full max-h-[200px] overflow-hidden rounded-md">
<img
src={imagePreview}
alt="Image preview"
className="max-w-full max-h-[180px] object-contain mx-auto"
/>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-1 right-1 h-6 w-6 rounded-full"
onClick={handleRemoveImage}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="relative"> <form onSubmit={handleSubmit} className="relative">
<div <div
className={`flex items-center space-x-2 p-1 rounded-full border ${ className={`flex items-center space-x-2 p-1 rounded-full border ${
@ -136,10 +193,19 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
variant="ghost" variant="ghost"
size="icon" 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" 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}
> >
<Paperclip className="h-5 w-5" /> <Image className="h-5 w-5" />
</Button> </Button>
<input
type="file"
ref={imageInputRef}
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<Input <Input
ref={inputRef} ref={inputRef}
value={message} value={message}
@ -149,17 +215,19 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
placeholder={ placeholder={
isRateLimited isRateLimited
? 'Rate limited - please wait...' ? 'Rate limited - please wait...'
: 'Type your message...' : imagePreview
? 'Add a caption...'
: 'Type your message...'
} }
disabled={isRateLimited} disabled={isRateLimited}
className="flex-1 border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent py-2 px-3" 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 ? (
<Button <Button
type="submit" type="submit"
size="icon" size="icon"
disabled={!message.trim() || isRateLimited} disabled={(!message.trim() && !imagePreview) || isRateLimited}
className="rounded-full h-9 w-9 bg-blue-500 hover:bg-blue-600 text-white" className="rounded-full h-9 w-9 bg-blue-500 hover:bg-blue-600 text-white"
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
@ -176,7 +244,7 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
)} )}
</div> </div>
{/* Emoji picker placeholder - you'd need to implement or use a library like emoji-mart */} {/* Emoji picker TODO: implement emoji-mart */}
{showEmojiPicker && ( {showEmojiPicker && (
<div className="absolute bottom-full mb-2 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"> <div className="absolute bottom-full mb-2 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-8 gap-1"> <div className="grid grid-cols-8 gap-1">

View File

@ -4,7 +4,7 @@ import { useChatStore } from '@/stores/chatStore';
import { useSocketStore } from '@/stores/socketStore'; import { useSocketStore } from '@/stores/socketStore';
import type { Message } from '@/types'; import type { Message } from '@/types';
import { useQuery } from '@tanstack/react-query'; 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 { MessageCard } from './MessageCard';
import { TypingIndicator } from './TypingIndicator'; import { TypingIndicator } from './TypingIndicator';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
@ -17,6 +17,7 @@ export const MessageList: FC<MessageListProps> = ({ roomId }) => {
const { messages, setMessages } = useChatStore(); const { messages, setMessages } = useChatStore();
const { socket } = useSocketStore(); const { socket } = useSocketStore();
const { user } = useAuthStore(); const { user } = useAuthStore();
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null); const scrollAreaRef = useRef<HTMLDivElement>(null);
@ -43,6 +44,43 @@ export const MessageList: FC<MessageListProps> = ({ roomId }) => {
} }
}, [isLoading, 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(() => { useEffect(() => {
if (!socket || !user || !roomId || roomMessages.length === 0) return; if (!socket || !user || !roomId || roomMessages.length === 0) return;

View File

@ -31,6 +31,7 @@ export type ChatRoomMember = {
export type Message = { export type Message = {
id: string; id: string;
content: string; content: string;
image?: string;
createdAt: string; createdAt: string;
userId: string; userId: string;
roomId: string; roomId: string;