diff --git a/frontend/src/components/chat/messages/ValidChat.jsx b/frontend/src/components/chat/messages/ValidChat.jsx index 6dde101..89514dc 100644 --- a/frontend/src/components/chat/messages/ValidChat.jsx +++ b/frontend/src/components/chat/messages/ValidChat.jsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { Hash, Pencil, Trash2, Save, SendHorizontal, Loader2, AlertCircle, Pin } from "lucide-react"; +import { Hash, Pencil, Trash2, Save, SendHorizontal, Loader2, AlertCircle,Reply, Pin, X } from "lucide-react"; import socket from "../../socket/Socket"; import { useParams } from "react-router-dom"; import { clear_channel_unread } from "../../../store/unreadSlice"; @@ -32,47 +32,11 @@ function ValidChat() { const [editingContent, setEditingContent] = useState(""); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [typingUsers, setTypingUsers] = useState({}); - const messagesEndRef = useRef(null); - const typingTimeoutRef = useRef(null); - const typingUserTimeoutsRef = useRef({}); - const isTypingRef = useRef(false); - - const stopTyping = () => { - if (!isTypingRef.current) { - return; - } - - clearTimeout(typingTimeoutRef.current); - isTypingRef.current = false; - socket.emit("server_stop_typing", { channel_id, server_id }); - }; - - const handleMessageChange = (e) => { - const nextMessage = e.target.value; - setchat_message(nextMessage); - - if (!channel_id || !server_id || !id) { - return; - } - - if (!nextMessage.trim()) { - stopTyping(); - return; - } - - if (!isTypingRef.current) { - isTypingRef.current = true; - socket.emit("server_typing", { - channel_id, - server_id, - username, - }); - } - - clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = setTimeout(stopTyping, 2000); - }; + const [replyTo, setReplyTo] = useState(null); + const [showPinned, setShowPinned] = useState(false); + const pinnedMessages = all_messages.filter( + (message) => message.isPinned + ); useEffect(() => { if(socket && channel_id){ @@ -109,12 +73,16 @@ function ValidChat() { tag, id, profile_pic, + replyTo, }), }); const data = await res.json(); if (data.status !== 200) { setchat_message(chat_message); } + if (data.status === 200) { + setReplyTo(null); + } }; useEffect(() => { @@ -231,32 +199,31 @@ function ValidChat() { }; const togglePinMessage = async (message) => { - const res = await fetch(`${url}/chat/toggle_server_message_pin`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-auth-token": localStorage.getItem("token"), - }, - body: JSON.stringify({ - server_id, - channel_id, - timestamp: message.timestamp, - sender_id: message.sender_id, - }), - }); - - const data = await res.json(); - if (data.status === 200) { - setall_messages((currentMessages) => - currentMessages.map((entry) => - String(entry.timestamp) === String(message.timestamp) && - String(entry.sender_id) === String(message.sender_id) - ? { ...entry, is_pinned: data.is_pinned } - : entry - ) - ); - } - }; + const res = await fetch(`${url}/chat/toggle_pin_message`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-auth-token": localStorage.getItem("token"), + }, + body: JSON.stringify({ + server_id, + channel_id, + timestamp: message.timestamp, + }), + }); + + const data = await res.json(); + + if (data.status === 200) { + setall_messages((currentMessages) => + currentMessages.map((entry) => + String(entry.timestamp) === String(message.timestamp) + ? { ...entry, isPinned: data.isPinned } + : entry + ) + ); + } +}; useEffect(() => { const handleReceiveMessage = (messageData) => { @@ -306,77 +273,26 @@ function ValidChat() { ) ); }; - - const handlePinUpdatedMessage = (message_data) => { + const handlePinUpdated = (message_data) => { setall_messages((currentMessages) => (currentMessages || []).map((entry) => - String(entry.timestamp) === String(message_data.timestamp) && - entry.sender_id === message_data.sender_id - ? { ...entry, is_pinned: message_data.is_pinned } + String(entry.timestamp) === String(message_data.timestamp) + ? { ...entry, isPinned: message_data.isPinned } : entry ) ); }; - - const handleTyping = (typingData) => { - if ( - String(typingData?.server_id) !== String(server_id) || - String(typingData?.channel_id) !== String(channel_id) || - String(typingData?.from) === String(id) - ) { - return; - } - - setTypingUsers((currentUsers) => ({ - ...currentUsers, - [String(typingData.from)]: typingData.username || "Someone", - })); - - clearTimeout(typingUserTimeoutsRef.current[String(typingData.from)]); - typingUserTimeoutsRef.current[String(typingData.from)] = setTimeout(() => { - setTypingUsers((currentUsers) => { - const nextUsers = { ...currentUsers }; - delete nextUsers[String(typingData.from)]; - return nextUsers; - }); - delete typingUserTimeoutsRef.current[String(typingData.from)]; - }, 3000); - }; - - const handleStopTyping = (typingData) => { - if ( - String(typingData?.server_id) !== String(server_id) || - String(typingData?.channel_id) !== String(channel_id) - ) { - return; - } - - setTypingUsers((currentUsers) => { - const nextUsers = { ...currentUsers }; - delete nextUsers[String(typingData.from)]; - return nextUsers; - }); - clearTimeout(typingUserTimeoutsRef.current[String(typingData.from)]); - delete typingUserTimeoutsRef.current[String(typingData.from)]; - }; - //earlier it was server_message_receive which was wrong socket.on("server_message_received", handleReceiveMessage); socket.on("server_message_updated", handleUpdatedMessage); socket.on("server_message_deleted", handleDeletedMessage); - socket.on("server_message_pin_updated", handlePinUpdatedMessage); - socket.on("server_typing", handleTyping); - socket.on("server_stop_typing", handleStopTyping); + socket.on("server_message_pin_updated", handlePinUpdated); return () => { socket.off("server_message_received", handleReceiveMessage); socket.off("server_message_updated", handleUpdatedMessage); socket.off("server_message_deleted", handleDeletedMessage); - socket.off("server_message_pin_updated", handlePinUpdatedMessage); - socket.off("server_typing", handleTyping); - socket.off("server_stop_typing", handleStopTyping); - Object.values(typingUserTimeoutsRef.current).forEach(clearTimeout); - typingUserTimeoutsRef.current = {}; + socket.off("server_message_pin_updated", handlePinUpdated); }; }, [channel_id, id, server_id]); @@ -408,8 +324,18 @@ function ValidChat() {
-
- Welcome to #{channel_name}! +
+
+ Welcome to #{channel_name} +
+ +
This is the start of the #{channel_name} channel. Send a message to start the conversation! @@ -433,6 +359,100 @@ function ValidChat() {
+ {showPinned && ( +
+
+ 📌 Pinned Messages +
+ + {pinnedMessages.length === 0 ? ( +
+ No pinned messages yet. +
+ ) : ( + pinnedMessages.map((msg) => ( +
+
+ {msg.sender_name} +
+ +
+ {msg.content} +
+
+ )) + )} +
+ )} + +
+ +
+ + {showPinned && ( +
+
+ 📌 Pinned Messages +
+ + {pinnedMessages.length === 0 ? ( +
+ No pinned messages yet. +
+ ) : ( + pinnedMessages.map((msg) => ( + + )) + )} +
+ )} +
{(all_messages || []).map((elem) => { const date = new Date(Number(elem.timestamp)); @@ -445,6 +465,7 @@ function ValidChat() { return (
@@ -465,50 +486,61 @@ function ValidChat() {
{timestamp}
- {elem.is_pinned ? ( -
- - Pinned -
- ) : null} -
- {mine ? ( -
- - -
- ) : null} - {isServerOwner ? ( + {mine ? ( +
+ + - ) : null} + + + +
+ ) : null}
{isEditing ? ( @@ -539,9 +571,20 @@ function ValidChat() {
) : ( -
- {elem.content} -
+
+ {elem.replyTo && ( +
+
+ {elem.replyTo.sender_name} +
+
+ {elem.replyTo.content} +
+
+ )} + + {elem.content} +
)}
@@ -560,6 +603,26 @@ function ValidChat() { ) : null}
+ {replyTo && ( +
+
+
+ Replying to {replyTo.sender_name} +
+
+ {replyTo.content} +
+
+ + +
+ )}
{ @@ -247,6 +252,32 @@ function DirectMessage() { } }; + const togglePinMessage = async (message) => { + const res = await fetch(`${url}/direct-messages/toggle_pin_direct_message`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-auth-token": localStorage.getItem("token"), + }, + body: JSON.stringify({ + friend_id: activeFriend.id, + timestamp: message.timestamp, + }), + }); + + const data = await res.json(); + + if (data.status === 200) { + setMessages((currentMessages) => + currentMessages.map((entry) => + String(entry.timestamp) === String(message.timestamp) + ? { ...entry, isPinned: data.isPinned } + : entry + ) + ); + } +}; + if (!activeFriend) { return null; } @@ -334,6 +365,18 @@ function DirectMessage() { : "border-white/10 bg-white/5 text-white/85", ].join(" ")} > + + {message.replyTo && ( +
+
+ {message.replyTo.sender_name} +
+
+ {message.replyTo.content} +
+
+ )} + {message.content}
@@ -345,6 +388,30 @@ function DirectMessage() { "group-hover:opacity-100 focus-within:opacity-100", ].join(" ")} > + + + + + +
+ )}
{ diff --git a/server/src/models/Chat.js b/server/src/models/Chat.js index e919aea..c3f0376 100644 --- a/server/src/models/Chat.js +++ b/server/src/models/Chat.js @@ -7,16 +7,27 @@ const chatSchema = new mongoose.Schema({ channel_id: String, channel_name: String, chat_details: [ - { - content: String, - sender_id: String, + { + content: String, + sender_id: String, + sender_name: String, + sender_pic: String, + sender_tag: String, + timestamp: String, + + replyTo: { sender_name: String, - sender_pic: String, - sender_tag: String, + content: String, timestamp: String, is_pinned: { type: Boolean, default: false }, }, - ], + + isPinned: { + type: Boolean, + default: false, + }, + }, + ], }, ], }); diff --git a/server/src/models/DirectMessageThread.js b/server/src/models/DirectMessageThread.js index cb6d367..3466a00 100644 --- a/server/src/models/DirectMessageThread.js +++ b/server/src/models/DirectMessageThread.js @@ -10,8 +10,19 @@ const directMessageThreadSchema = new mongoose.Schema({ sender_pic: String, content: String, timestamp: Number, + + replyTo: { + sender_name: String, + content: String, + timestamp: Number, + }, + + isPinned: { + type: Boolean, + default: false, + }, }, ], }); -export default mongoose.model("direct_message_threads", directMessageThreadSchema); +export default mongoose.model("direct_message_threads", directMessageThreadSchema); \ No newline at end of file diff --git a/server/src/routes/chat.js b/server/src/routes/chat.js index 78ed84a..7fd151a 100644 --- a/server/src/routes/chat.js +++ b/server/src/routes/chat.js @@ -67,6 +67,7 @@ router.post("/store_message", expressRateLimit("chat"), storeMessageValidator, v tag, id, profile_pic, + replyTo, } = req.body; const chatMessage = { @@ -76,7 +77,8 @@ router.post("/store_message", expressRateLimit("chat"), storeMessageValidator, v sender_pic: profile_pic, sender_tag: tag, timestamp, - is_pinned: false, + replyTo: replyTo || null, + isPinned: false, }; const response = await Chat.find({ @@ -377,4 +379,54 @@ router.post("/delete_server_message", deleteServerMessageValidator, validate, as } }); +router.post("/toggle_pin_message", async (req, res) => { + const { server_id, channel_id, timestamp } = req.body; + const user = getAuthorizedUser(req, res); + if (!user) return; + + if (!server_id || !channel_id || !timestamp) { + return res.status(400).json({ status: 400, message: "Invalid input" }); + } + + const chatDoc = await Chat.findOne({ + server_id, + "channels.channel_id": channel_id, + }); + + if (!chatDoc) { + return res.status(404).json({ status: 404, message: "Chat not found" }); + } + + const channel = chatDoc.channels.find( + (entry) => entry.channel_id === channel_id + ); + + const message = channel?.chat_details.find( + (entry) => String(entry.timestamp) === String(timestamp) + ); + + if (!message) { + return res.status(404).json({ status: 404, message: "Message not found" }); + } + + message.isPinned = !message.isPinned; + + await chatDoc.save(); + await cache.del(`chat:${server_id}:${channel_id}`); + + const io = getIO(); + if (io) { + io.to(`channel:${channel_id}`).emit("server_message_pin_updated", { + timestamp, + isPinned: message.isPinned, + }); + } + + return res.status(200).json({ + status: 200, + message: "Pin status updated", + isPinned: message.isPinned, + }); +}); + export default router; diff --git a/server/src/routes/directMessages.js b/server/src/routes/directMessages.js index 8affdc6..456cf1f 100644 --- a/server/src/routes/directMessages.js +++ b/server/src/routes/directMessages.js @@ -77,7 +77,7 @@ router.post("/send_direct_message", sendDirectMessageValidator, validate, async return; } - const { friend_id, content } = req.body; + const { friend_id, content, replyTo } = req.body; const currentUser = await User.findOne({ _id: user.id }).lean(); const friend = await User.findOne({ _id: friend_id }).lean(); @@ -101,6 +101,8 @@ router.post("/send_direct_message", sendDirectMessageValidator, validate, async sender_pic: currentUser.profile_pic, content: content.trim(), timestamp: Date.now(), + replyTo: replyTo || null, + isPinned: false, }; const participants = getThreadParticipants(currentUserId, friendUserId); @@ -126,6 +128,8 @@ router.post("/send_direct_message", sendDirectMessageValidator, validate, async sender_pic: currentUser.profile_pic, content: message.content, timestamp: message.timestamp, + replyTo: message.replyTo, + isPinned: message.isPinned, }); io.to(currentUserId).emit("direct_message_received", { friend_id: friendUserId, @@ -135,6 +139,8 @@ router.post("/send_direct_message", sendDirectMessageValidator, validate, async sender_pic: currentUser.profile_pic, content: message.content, timestamp: message.timestamp, + replyTo: message.replyTo, + isPinned: message.isPinned, }); const shouldNotify = await shouldSendNotification(friendUserId, "direct_messages"); if (shouldNotify) { @@ -242,4 +248,56 @@ router.post("/delete_direct_message", deleteDirectMessageValidator, validate, as return res.status(200).json({ status: 200, message: "Message deleted" }); }); +router.post("/toggle_pin_direct_message", async (req, res) => { + const user = getAuthorizedUser(req, res); + if (!user) return; + + const { friend_id, timestamp } = req.body; + + if (!friend_id || !timestamp) { + return res.status(400).json({ status: 400, message: "Invalid input" }); + } + + const participants = getThreadParticipants(user.id, friend_id); + const thread = await DirectMessageThread.findOne({ participants }); + + if (!thread) { + return res.status(404).json({ status: 404, message: "Thread not found" }); + } + + const message = thread.messages.find( + (entry) => String(entry.timestamp) === String(timestamp) + ); + + if (!message) { + return res.status(404).json({ status: 404, message: "Message not found" }); + } + + message.isPinned = !message.isPinned; + + await thread.save(); + await cache.del(`dm:${participants[0]}:${participants[1]}`); + + const io = getIO(); + if (io) { + io.to(friend_id).emit("direct_message_pin_updated", { + friend_id: user.id, + timestamp, + isPinned: message.isPinned, + }); + + io.to(user.id).emit("direct_message_pin_updated", { + friend_id, + timestamp, + isPinned: message.isPinned, + }); + } + + return res.status(200).json({ + status: 200, + message: "Pin status updated", + isPinned: message.isPinned, + }); +}); + export default router;