diff --git a/.env.example b/.env.example index c6f1268..ea5b08d 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ NODE_ENV=development NEXT_PUBLIC_NODE_ENV=development +NEXT_PUBLIC_SUPABASE_BUCKET_NAME= NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx index df8e2ba..53692e2 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -4,9 +4,15 @@ import { useEffect, useRef, useState } from "react"; import { RealtimeChannel, User } from "@supabase/supabase-js"; import { createClient } from "../lib/supabase/client"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faPaperPlane, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { + faFile, + faPaperPlane, + faPlus, +} from "@fortawesome/free-solid-svg-icons"; import Conversations from "./chat/Conversations"; import Messages from "./chat/Messages"; +import Image from "next/image"; +import { toast } from "react-toastify"; export interface Conversation { id: string; @@ -19,6 +25,12 @@ export interface Message { conversation_id: string; sender_id: string; text: string; + attachments: { + filename: string; + mimetype: string; + filesize: number; + public_url: string; + }[]; created_at: string; } @@ -39,6 +51,8 @@ export interface ConversationParticipantRow { conversation: ConversationParticipant[]; } +type Attachment = File; + const supabase = createClient(); export default function Chat({ user }: { user: User }) { @@ -54,6 +68,9 @@ export default function Chat({ user }: { user: User }) { const bottomRef = useRef(null); const [badWords, setBadWords] = useState([]); const creatingRef = useRef(false); + const fileInputRef = useRef(null); + const [attachments, setAttachments] = useState([]); + const bucketName = process.env.NEXT_PUBLIC_SUPABASE_BUCKET_NAME || ""; useEffect(() => { fetch( @@ -66,13 +83,6 @@ export default function Chat({ user }: { user: User }) { }); }, []); - const sanitizeInput = (input: string) => { - if (!badWords.length) return input; - - const filter = new RegExp(`\\b(${badWords.join("|")})\\b`, "gi"); - return input.replace(filter, "*-?;[]"); - }; - useEffect(() => { if (textareaRef.current) { const el = textareaRef.current; @@ -207,6 +217,7 @@ export default function Chat({ user }: { user: User }) { conversation_id: payload.new.conversation_id, sender_id: payload.new.sender_id, text: payload.new.text, + attachments: payload.new.attachments, created_at: payload.new.created_at, }, ]); @@ -226,7 +237,7 @@ export default function Chat({ user }: { user: User }) { .eq("conversation_id", conversationId) .order("created_at", { ascending: true }); if (data) { - setMessages(data); + setMessages(data as Message[]); setTimeout(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, 100); @@ -261,6 +272,24 @@ export default function Chat({ user }: { user: User }) { fetchUsers(); }, [showModal, user.id]); + const handleFileChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (!files.length) return; + + setAttachments((prev) => [...prev, ...files]); + }; + + const removeAttachment = (index: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== index)); + }; + + const sanitizeInput = (input: string) => { + if (!badWords.length) return input; + + const filter = new RegExp(`\\b(${badWords.join("|")})\\b`, "gi"); + return input.replace(filter, "*-?;[]"); + }; + const createConversation = async (otherUser: ChatUser) => { if (creatingRef.current) return; creatingRef.current = true; @@ -320,19 +349,62 @@ export default function Chat({ user }: { user: User }) { }; const sendMessage = async () => { - if (!input.trim() || !conversationId) return; + if ((!input.trim() && attachments.length === 0) || !conversationId) return; + + try { + const uploadedAttachments = await Promise.all( + attachments.map(async (file) => { + if (!bucketName || bucketName.length === 0) { + toast.error("Storage bucket is not configured."); + return null; + } + if (file.size > 10 * 1024 * 1024) { + toast.error(`${file.name} is too large. Max size is 10MB.`); + return null; + } + + const filePath = `messages/${conversationId}/${Date.now()}-${file.name}`; + + const { error: uploadError } = await supabase.storage + .from(bucketName) + .upload(filePath, file); + + if (uploadError) { + console.error("Upload error:", uploadError); + return null; + } + + const { data } = supabase.storage + .from(bucketName) + .getPublicUrl(filePath); - await supabase.from("messages").insert({ - conversation_id: conversationId, - sender_id: user.id, - text: sanitizeInput(input.slice(0, 1000)), // limit to 1000 chars - }); + return { + filename: file.name, + mimetype: file.type, + filesize: file.size, + public_url: data.publicUrl, + }; + }), + ); - setTimeout(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); + const validAttachments = uploadedAttachments.filter(Boolean); - setInput(""); + await supabase.from("messages").insert({ + conversation_id: conversationId, + sender_id: user.id, + text: sanitizeInput(input.slice(0, 1000)), + attachments: validAttachments, + }); + + setTimeout(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, 100); + + setInput(""); + setAttachments([]); + } catch (err) { + console.error("Send message error:", err); + } }; return ( @@ -368,7 +440,48 @@ export default function Chat({ user }: { user: User }) { />
+ {attachments.length > 0 && ( +
+ {attachments.map((file, index) => ( +
+ {file.type.startsWith("image/") ? ( + {file.name} + ) : ( + + )} + {file.name} + +
+ ))} +
+ )} +
+ +