diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index 748111d..b58f641 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -2,6 +2,8 @@ import { useState, useEffect, useRef } from "react"; import { io, Socket } from "socket.io-client"; +import EmojiPicker from "emoji-picker-react"; +import { Smile, Paperclip, Mic, Square } from "lucide-react"; type Message = { id?: string; @@ -9,6 +11,8 @@ type Message = { text: string; time?: string; status?: string; + image?: string; + audio?: string; reactions?: { [emoji: string]: number }; }; @@ -18,6 +22,13 @@ export default function ChatPage() { const [username, setUsername] = useState(""); const [typingUser, setTypingUser] = useState(""); const [onlineUsers, setOnlineUsers] = useState([]); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + const [isRecording, setIsRecording] = useState(false); +const [audioBlob, setAudioBlob] = useState(null); + +const mediaRecorderRef = useRef(null); +const audioChunksRef = useRef([]); const socketRef = useRef(null); const typingTimeoutRef = useRef(null); @@ -48,6 +59,9 @@ export default function ChatPage() { // NEW MESSAGE socket.on("newMessage", (msg) => { + // prevent duplicate message for sender + if (msg.username === username) return; + setMessages((prev) => [ ...prev, { @@ -59,7 +73,6 @@ socket.on("newMessage", (msg) => { }, ]); - // new logic if (!isAtBottomRef.current) { setUnreadCount((prev) => prev + 1); } @@ -91,14 +104,10 @@ socket.on("newMessage", (msg) => { ); }); socket.on("reactionUpdate", ({ messageId, reactions }) => { - setMessages((prev) => - prev.map((msg) => - msg.id === messageId - ? { ...msg, reactions } - : msg - ) - ); -}); + setMessages((prev) => + prev.map((msg) => + msg.id === messageId ? { ...msg, reactions } : msg )); + }); return () => { socket.disconnect(); @@ -119,7 +128,7 @@ socket.on("newMessage", (msg) => { } }, [messages]); - // MARK ONLY LAST MESSAGE AS SEEN (FIXED) + // MARK ONLY LAST MESSAGE AS SEEN useEffect(() => { if (!socketRef.current) return; @@ -131,18 +140,42 @@ socket.on("newMessage", (msg) => { // SEND async function handleSend() { - if (!input.trim() || !username.trim()) return; - - await fetch("http://localhost:5000/api/chat", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ text: input, username }), - }); + if ((!input.trim() && !selectedImage && !audioBlob) || !username.trim()) return; + + const localMessage: Message = { + id: Date.now().toString(), + user: username, + text: input, + image: selectedImage || undefined, + audio: audioBlob || undefined, + time: new Date().toLocaleTimeString(), + status: "sent", +}; - setInput(""); - } + // immediately show message in UI + setMessages((prev) => [...prev, localMessage]); + + // send text to backend + await fetch("http://localhost:5000/api/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: input, + username, + }), + }); + + // clear inputs + setInput(""); + setSelectedImage(null); + setAudioBlob(null); + // auto scroll + bottomRef.current?.scrollIntoView({ + behavior: "smooth", + }); +} // TYPING function handleTyping(e: any) { @@ -152,20 +185,73 @@ socket.on("newMessage", (msg) => { socketRef.current.emit("typing", username); } } - function handleReact(messageId: string | undefined, emoji: string) { - if (!messageId) return; + //emoji select function + function handleEmojiClick(emojiData: any) { + setInput((prev) => prev + emojiData.emoji); + setShowEmojiPicker(false); + } + function handleImageUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + + if (!file) return; + + const imageUrl = URL.createObjectURL(file); + setSelectedImage(imageUrl); + } + + async function startRecording() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + + const mediaRecorder = new MediaRecorder(stream); - // send reaction to server - socketRef.current?.emit("react", { messageId, emoji, username,}); + mediaRecorderRef.current = mediaRecorder; + + audioChunksRef.current = []; + + mediaRecorder.ondataavailable = (event) => { + audioChunksRef.current.push(event.data); + }; + + mediaRecorder.onstop = () => { + const audioBlob = new Blob(audioChunksRef.current, { + type: "audio/webm", + }); + + const audioUrl = URL.createObjectURL(audioBlob); + + setAudioBlob(audioUrl); + }; + + mediaRecorder.start(); + + setIsRecording(true); + } catch (error) { + console.error("Recording failed:", error); + } +} +function stopRecording() { + mediaRecorderRef.current?.stop(); + setIsRecording(false); } + function handleReact(messageId: string | undefined, emoji: string) { + if (!messageId) return; + + // send reaction to server + socketRef.current?.emit("react", { messageId, emoji, username,}); + } + return ( -
+
+ {/* HEADER */}

Team Chat @@ -188,136 +274,267 @@ return (
- - Online: - - - {onlineUsers.join(", ")} - -
+ Online: + + {onlineUsers.join(", ")} +

{/* MESSAGES */}
{ - const el = containerRef.current; - if (!el) return; + ref={containerRef} + onScroll={() => { + const el = containerRef.current; + if (!el) return; - const threshold = 100; + const threshold = 100; - const atBottom = - el.scrollHeight - el.scrollTop - el.clientHeight < threshold; + const atBottom = + el.scrollHeight - el.scrollTop - el.clientHeight < threshold; - isAtBottomRef.current = atBottom; + isAtBottomRef.current = atBottom; - // reset unread when user reaches bottom - if (atBottom) { - setUnreadCount(0); - } - }} - className="h-[500px] overflow-y-auto rounded-3xl border border-(--line) bg-white p-5 shadow-sm" -> - {messages.map((msg, i) => { - const prevMsg = messages[i - 1]; - const isSameUser = prevMsg && prevMsg.user === msg.user; - const isMe = msg.user === username; - - return ( + if (atBottom) { + setUnreadCount(0); + } + }} + className="h-[500px] overflow-y-auto rounded-3xl border border-(--line) bg-white p-5 shadow-sm" + > + {messages.map((msg, i) => { + const prevMsg = messages[i - 1]; + const isSameUser = prevMsg && prevMsg.user === msg.user; + const isMe = msg.user === username; + + return ( +
-
- {/* Show name only for others */} - {!isMe && !isSameUser && ( -

{msg.user}

- )} - -

{msg.text}

-
- {["👍", "❤️", "😂"].map((emoji) => ( - - ))} - {/* counts will show here*/} - {msg.reactions && - Object.entries(msg.reactions).filter(([_, count]) => count > 0).map(([emoji, count]) => ( - + {/* USERNAME */} + {!isMe && !isSameUser && ( +

+ {msg.user} +

+ )} + + {/* TEXT */} + {msg.text && ( +

+ {msg.text} +

+ )} + + {/* IMAGE */} + {msg.image && msg.image.startsWith("blob:") && ( + Shared image + )} + + {/* AUDIO */} + {msg.audio && msg.audio.startsWith("blob:") && ( + + )} + + {/* REACTIONS */} +
+ {["👍", "❤️", "😂"].map((emoji) => ( + + ))} + + {msg.reactions && + Object.entries(msg.reactions) + .filter(([_, count]) => count > 0) + .map(([emoji, count]) => ( + {emoji} {count} ))} -
- -

- {msg.time} {msg.status === "seen" ? "✓✓" : "✓"} -

+ + {/* TIME */} +

+ {msg.time}{" "} + {msg.status === "seen" ? "✓✓" : "✓"} +

- ); - })} +
+ ); + })} -
-
- {unreadCount > 0 && ( -
- -
-)} +
+
- {/* TYPING */} - {typingUser && typingUser !== username && ( -

- {typingUser} is typing... -

- )} + {/* UNREAD MESSAGE */} + {unreadCount > 0 && ( +
+ +
+ )} - {/* INPUT */} -
- + {typingUser} is typing... +

+ )} + + {/* IMAGE PREVIEW */} + {selectedImage && ( +
+ Preview +
+ )} + + {/* RECORDING */} + {isRecording && ( +

+ Recording voice message... +

+ )} + + {/* AUDIO PREVIEW */} + {audioBlob && ( +
+ +
+ )} + + {/* INPUT SECTION */} +
+ + {/* EMOJI */} +
+ + {showEmojiPicker && ( +
+ +
+ )}
+ + {/* FILE UPLOAD */} + + + {/* VOICE */} + + + {/* MESSAGE INPUT */} + + + {/* SEND BUTTON */} +
- ); +
+); } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index f8b7323..9db6a4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@supabase/supabase-js": "^2.105.1", "axios": "^1.15.0", "debug": "^4.4.3", + "emoji-picker-react": "^4.19.1", "frontend": "file:", "lightningcss": "^1.32.0", "lucide-react": "^1.14.0", diff --git a/package-lock.json b/package-lock.json index 75670e4..c5e49e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@supabase/supabase-js": "^2.105.1", "axios": "^1.15.0", "debug": "^4.4.3", + "emoji-picker-react": "^4.19.1", "frontend": "file:", "lightningcss": "^1.32.0", "lucide-react": "^1.14.0", @@ -5094,6 +5095,21 @@ "node": ">= 0.4" } }, + "node_modules/emoji-picker-react": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.19.1.tgz", + "integrity": "sha512-BmDdqInKFVYJpv7qS9WI6L9656cDAC+FkDvUjJds56nKHbaVTBNeDmLwKBytRnzu37zWHs9Isg7gt5PT43y6xA==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/engine.io-client": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", @@ -5198,6 +5214,12 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",