images
This commit is contained in:
parent
0342c0d295
commit
95757dd169
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "messages" ADD COLUMN "image" TEXT;
|
||||
@ -61,6 +61,7 @@ model ChatRoomMember {
|
||||
model Message {
|
||||
id String @id @default(cuid())
|
||||
content String
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@ -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<ApiResponse> {
|
||||
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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -24,6 +24,7 @@ export type AuthenticatedSocket = Socket & {
|
||||
export type SendMessageRequest = {
|
||||
roomId: string;
|
||||
content: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
export type ReactToMessageRequest = {
|
||||
|
||||
@ -104,6 +104,15 @@ export const MessageCard: FC<{ message: Message }> = ({ message }) => {
|
||||
onTouchStart={handleMouseDown}
|
||||
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">
|
||||
{message.content}
|
||||
</div>
|
||||
|
||||
@ -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<MessageInputProps> = ({ roomId }) => {
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const { socket } = useSocket();
|
||||
const { user } = useAuthStore();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -54,11 +56,12 @@ export const MessageInput: FC<MessageInputProps> = ({ 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<MessageInputProps> = ({ roomId }) => {
|
||||
}
|
||||
|
||||
setMessage('');
|
||||
setImagePreview(null);
|
||||
|
||||
if (imageInputRef.current) {
|
||||
imageInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiClick = (emoji: string) => {
|
||||
@ -100,6 +108,34 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
|
||||
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(() => {
|
||||
return () => {
|
||||
if (typingTimeoutRef.current) {
|
||||
@ -115,6 +151,27 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
|
||||
return (
|
||||
<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">
|
||||
{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">
|
||||
<div
|
||||
className={`flex items-center space-x-2 p-1 rounded-full border ${
|
||||
@ -136,10 +193,19 @@ export const MessageInput: FC<MessageInputProps> = ({ 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}
|
||||
>
|
||||
<Paperclip className="h-5 w-5" />
|
||||
<Image className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={imageInputRef}
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
@ -149,17 +215,19 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
|
||||
placeholder={
|
||||
isRateLimited
|
||||
? 'Rate limited - please wait...'
|
||||
: 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 ? (
|
||||
<Button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
@ -176,7 +244,7 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji picker placeholder - you'd need to implement or use a library like emoji-mart */}
|
||||
{/* Emoji picker TODO: implement emoji-mart */}
|
||||
{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="grid grid-cols-8 gap-1">
|
||||
|
||||
@ -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<MessageListProps> = ({ roomId }) => {
|
||||
const { messages, setMessages } = useChatStore();
|
||||
const { socket } = useSocketStore();
|
||||
const { user } = useAuthStore();
|
||||
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
@ -43,6 +44,43 @@ export const MessageList: FC<MessageListProps> = ({ 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;
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ export type ChatRoomMember = {
|
||||
export type Message = {
|
||||
id: string;
|
||||
content: string;
|
||||
image?: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
roomId: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user