From f76ce10227e490cbbc7592b9491749a69658fbdb Mon Sep 17 00:00:00 2001 From: SrashtiChauhan Date: Fri, 15 May 2026 14:50:27 +0530 Subject: [PATCH] feat(chat): add whatsapp-like reply, edit, and interaction support (NSOC'26) --- frontend/app/chat/page.tsx | 318 ++++++++++++++++++++++++++++++++++--- package-lock.json | 18 +-- package.json | 3 +- 3 files changed, 307 insertions(+), 32 deletions(-) diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index b58f641..b3ccb7f 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -14,6 +14,11 @@ type Message = { image?: string; audio?: string; reactions?: { [emoji: string]: number }; + replyTo?: { + user: string; + text: string; + }; + isPinned?: boolean; }; export default function ChatPage() { @@ -25,10 +30,17 @@ export default function ChatPage() { 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 [audioBlob, setAudioBlob] = useState(null); + + const [replyingTo, setReplyingTo] = useState(null); + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState("all"); + const [activeMessageId, setActiveMessageId] = useState(null); + const matchedMessageRef = useRef(null); + const [editingMessageId, setEditingMessageId] = useState(null); + const [editedText, setEditedText] = useState(""); const socketRef = useRef(null); const typingTimeoutRef = useRef(null); @@ -37,6 +49,7 @@ const audioChunksRef = useRef([]); const bottomRef = useRef(null); const isAtBottomRef = useRef(true); const [unreadCount, setUnreadCount] = useState(0); + // SOCKET SETUP useEffect(() => { @@ -121,13 +134,23 @@ socket.on("newMessage", (msg) => { } }, [username]); - // SMART AUTO SCROLL (FIXED) + // SMART AUTO SCROLL (FIXED) useEffect(() => { if (isAtBottomRef.current) { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); } }, [messages]); + // SEARCH AUTO SCROLL + useEffect(() => { + if (searchQuery && matchedMessageRef.current) { + matchedMessageRef.current.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, [searchQuery]); + // MARK ONLY LAST MESSAGE AS SEEN useEffect(() => { if (!socketRef.current) return; @@ -143,17 +166,48 @@ socket.on("newMessage", (msg) => { 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", -}; + id: Date.now().toString(), + user: username, + text: input, + time: new Date().toLocaleTimeString(), + status: "sent", + + replyTo: replyingTo + ? { + user: replyingTo.user, + text: replyingTo.text, + } + : undefined, - // immediately show message in UI - setMessages((prev) => [...prev, localMessage]); + ...(selectedImage && { image: selectedImage }), + ...(audioBlob && { audio: audioBlob }), + }; + + + // EDIT EXISTING MESSAGE + if (editingMessageId) { + setMessages((prev) => + prev.map((msg) => + msg.id === editingMessageId + ? { + ...msg, + text: input, + } + : msg + ) + ); + + setEditingMessageId(null); + setEditedText(""); + setInput(""); + + return; + } + + // ADD NEW MESSAGE + else { + setMessages((prev) => [...prev, localMessage]); + } // send text to backend await fetch("http://localhost:5000/api/chat", { @@ -171,6 +225,7 @@ socket.on("newMessage", (msg) => { setInput(""); setSelectedImage(null); setAudioBlob(null); + setReplyingTo(null); // auto scroll bottomRef.current?.scrollIntoView({ behavior: "smooth", @@ -243,6 +298,39 @@ function stopRecording() { // send reaction to server socketRef.current?.emit("react", { messageId, emoji, username,}); } + function handleReply(msg: Message) { + setReplyingTo(msg); + } + + function handleCopy(text: string) { + navigator.clipboard.writeText(text); + } + + function handleDelete(messageId?: string) { + setMessages((prev) => + prev.filter((msg) => msg.id !== messageId) + ); + } + + function handlePin(messageId?: string) { + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { ...msg, isPinned: !msg.isPinned } + : msg + ) + ); + } + function handleEdit(messageId?: string, currentText?: string) { + if (!messageId || !currentText) return; + + setEditingMessageId(messageId); + setEditedText(currentText); + + // load message into input + setInput(currentText); +} + @@ -279,6 +367,47 @@ return ( {onlineUsers.join(", ")} + {/* SEARCH + FILTER */} +
+ + {/* SEARCH */} +
+ setSearchQuery(e.target.value)} + placeholder="Search messages or users..." + className="w-full rounded-xl border border-(--line) px-4 py-2 pr-10 text-sm outline-none transition focus:border-slate-400" + /> + + {searchQuery && ( + + )} +
+ + {/* FILTER BUTTONS */} +
+ {["all", "image", "voice"].map((type) => ( + + ))} +
+
+ {/* MESSAGES */}
setActiveMessageId(msg.id || null)} + onMouseLeave={() => setActiveMessageId(null)} + onClick={() => + setActiveMessageId( + activeMessageId === msg.id + ? null + : msg.id || null + ) + } + className={`group relative flex ${ isMe ? "justify-end" : "justify-start" } ${isSameUser ? "mt-1" : "mt-4"}`} > @@ -333,12 +477,45 @@ return (

)} + {/* REPLIED MESSAGE */} + {msg.replyTo && ( +
+

+ {msg.replyTo.user} +

+ +

+ {msg.replyTo.text} +

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

- {msg.text} -

+ {searchQuery && + msg.text.toLowerCase().includes(searchQuery.toLowerCase()) ? ( + <> + {msg.text + .split(new RegExp(`(${searchQuery})`, "gi")) + .map((part, index) => + part.toLowerCase() === searchQuery.toLowerCase() ? ( + + {part} + + ) : ( + part + ) )} + + ) : ( + msg.text + )} +

+ )} {/* IMAGE */} {msg.image && msg.image.startsWith("blob:") && ( @@ -358,6 +535,63 @@ return ( )} + + {/* MESSAGE ACTIONS */} +
+ + + + + + {msg.text && ( + <> + + + {isMe && ( + + )} + + )} + + {isMe && ( + + )} +
+ {/* REACTIONS */}
{["👍", "❤️", "😂"].map((emoji) => ( @@ -458,6 +692,48 @@ return (
)} + {/* REPLY PREVIEW */} + {replyingTo && ( +
+
+

+ Replying to {replyingTo.user} +

+ +

+ {replyingTo.text} +

+
+ + +
+ )} + + {/* EDITING MESSAGE */} + {editingMessageId && ( +
+ + Editing message... + + + +
+ )} + {/* INPUT SECTION */}
@@ -523,7 +799,11 @@ return ( diff --git a/package-lock.json b/package-lock.json index 938ed30..df381b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@supabase/supabase-js": "^2.103.0", "lucide": "^1.8.0", - "react": "^19.2.5" + "react": "^19.2.4", + "react-dom": "^19.2.4" } }, "frontend": { @@ -27,7 +28,7 @@ "frontend": "file:", "lightningcss": "^1.32.0", "lucide-react": "^1.14.0", - "next": "^16.2.6", + "next": "16.2.6", "postcss": "^8.5.10", "react": "19.2.4", "react-dom": "19.2.4", @@ -3144,13 +3145,6 @@ ], "license": "MIT" }, - "frontend/node_modules/react": { - "version": "19.2.4", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "frontend/node_modules/react-is": { "version": "16.13.1", "dev": true, @@ -6163,9 +6157,9 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 48009d2..2d9b12e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@supabase/supabase-js": "^2.103.0", "lucide": "^1.8.0", - "react": "^19.2.5" + "react": "^19.2.4", + "react-dom": "^19.2.4" } }