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 {
|
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
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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...'
|
||||||
|
: imagePreview
|
||||||
|
? 'Add a caption...'
|
||||||
: 'Type your message...'
|
: '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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user