From a97e6cb9eab1d8d5cebabc5ebe32e44033375405 Mon Sep 17 00:00:00 2001 From: darshil2032007 Date: Tue, 9 Jun 2026 12:11:30 +0530 Subject: [PATCH] feat: add in-chat media gallery with lightbox image viewer Closes #499 --- backend/src/lib/socket.js | 18 +- backend/src/routes/message.route.js | 20 + frontend/components/chat/ChatWindow.jsx | 1870 +++++++++++--------- frontend/components/chat/ImageLightbox.jsx | 84 + frontend/components/chat/MediaGallery.jsx | 67 + frontend/components/chat/MessageBubble.jsx | 372 ++-- 6 files changed, 1400 insertions(+), 1031 deletions(-) create mode 100644 frontend/components/chat/ImageLightbox.jsx create mode 100644 frontend/components/chat/MediaGallery.jsx diff --git a/backend/src/lib/socket.js b/backend/src/lib/socket.js index 92f5f85..bd6839c 100644 --- a/backend/src/lib/socket.js +++ b/backend/src/lib/socket.js @@ -6,6 +6,23 @@ import Message from "../models/message.model.js"; import User from "../models/user.model.js"; const app = express(); +// Cache to track last DB update time per user +const lastDbUpdateCache = new Map(); + +// Throttled lastSeen updater - max once every 30 seconds per user +const throttledUpdateLastSeen = async (userId) => { + const now = Date.now(); + const lastUpdate = lastDbUpdateCache.get(userId) || 0; + + if (now - lastUpdate < 30000) return; // 30 second throttle + + lastDbUpdateCache.set(userId, now); + try { + await User.findByIdAndUpdate(userId, { lastSeen: new Date() }); + } catch (err) { + console.error("Error updating lastSeen:", err); + } +}; const server = http.createServer(app); const io = new Server(server, { @@ -70,7 +87,6 @@ const canCommunicate = async (senderId, receiverId) => { } }; -io.on("connection", (socket) => { const getActiveContacts = async (userId) => { try { const [senders, receivers] = await Promise.all([ diff --git a/backend/src/routes/message.route.js b/backend/src/routes/message.route.js index 04e02b7..dd83dd0 100644 --- a/backend/src/routes/message.route.js +++ b/backend/src/routes/message.route.js @@ -15,6 +15,26 @@ router.get("/:id", protectRoute, getMessages); router.post("/send/:id", protectRoute, sendMessage); router.post("/:id/react", protectRoute, reactToMessage); router.delete("/:id", protectRoute, deleteMessage); +// Media Gallery Route +router.get("/:id/media", protectRoute, async (req, res) => { + try { + const myId = req.user._id; + const theirId = req.params.id; + const mediaMessages = await Message.find({ + $or: [ + { senderId: myId, receiverId: theirId }, + { senderId: theirId, receiverId: myId }, + ], + image: { $ne: null, $exists: true }, + }) + .select("image createdAt senderId") + .sort({ createdAt: -1 }); + + res.status(200).json(mediaMessages); + } catch (error) { + res.status(500).json({ error: "Internal server error" }); + } +}); export default router; diff --git a/frontend/components/chat/ChatWindow.jsx b/frontend/components/chat/ChatWindow.jsx index c6c275b..c2a27bd 100644 --- a/frontend/components/chat/ChatWindow.jsx +++ b/frontend/components/chat/ChatWindow.jsx @@ -1,937 +1,1109 @@ -import { useEffect, useRef, useState, useCallback, useLayoutEffect } from "react" import { - Image, Images, Send, X, MessageSquare, - ArrowLeft, Smile, Mic, Square,Loader2, - Phone, Video, Trash2,Search, FileText, - NotebookPen, BarChart3, Sparkles, PenTool, Compass, Clock -} from "lucide-react" -import toast from "react-hot-toast" -import useAuthStore from "../../src/store/useAuthStore" -import useChatStore from "../../src/store/useChatStore" -import useCallStore from "../../src/store/useCallStore" -import useBookmarkStore from "../../src/store/useBookmarkStore" -import useRecording from "../../hooks/useRecording" -import useTypingIndicator from "../../hooks/useTypingIndicator" -import useContextMenu from "../../hooks/useContextMenu" -import axiosInstance from "../../lib/axios" -import Avatar from "./Avatar" -import ContextMenu from "./ContextMenu" -import ReplyBar from "./ReplyBar" -import EmojiPicker from "./EmojiPicker" -import MessageBubble from "./MessageBubble" -import NewChatModal from "./NewChatModal" + useEffect, + useRef, + useState, + useCallback, + useLayoutEffect, +} from "react"; +import { + Image, + Images, + Send, + X, + MessageSquare, + ArrowLeft, + Smile, + Mic, + Square, + Loader2, + Phone, + Video, + Trash2, + Search, + FileText, + NotebookPen, + BarChart3, + Sparkles, + PenTool, + Compass, + Clock, +} from "lucide-react"; +import toast from "react-hot-toast"; +import useAuthStore from "../../src/store/useAuthStore"; +import useChatStore from "../../src/store/useChatStore"; +import useCallStore from "../../src/store/useCallStore"; +import useBookmarkStore from "../../src/store/useBookmarkStore"; +import useRecording from "../../hooks/useRecording"; +import useTypingIndicator from "../../hooks/useTypingIndicator"; +import useContextMenu from "../../hooks/useContextMenu"; +import axiosInstance from "../../lib/axios"; +import Avatar from "./Avatar"; +import ContextMenu from "./ContextMenu"; +import ReplyBar from "./ReplyBar"; +import EmojiPicker from "./EmojiPicker"; +import MessageBubble from "./MessageBubble"; +import NewChatModal from "./NewChatModal"; import imageCompression from "browser-image-compression"; -import SmartReplySuggestions from "./SmartReplySuggestions" -import ScheduleMessageModal from "./ScheduleMessageModal" -import { getStatusMoodLabel } from "../../src/lib/statusMoods" - -const formatRecordingTime = (s) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}` +import SmartReplySuggestions from "./SmartReplySuggestions"; +import ScheduleMessageModal from "./ScheduleMessageModal"; +import { getStatusMoodLabel } from "../../src/lib/statusMoods"; +import ImageLightbox from "./ImageLightbox"; +const formatRecordingTime = (s) => + `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`; const formatLastSeen = (dateString) => { - if (!dateString) return "Offline"; - const date = new Date(dateString); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - if (isToday) return `Last seen today at ${time}`; - const nowCopy = new Date(); - const isYesterday = new Date(nowCopy.setDate(nowCopy.getDate() - 1)).toDateString() === date.toDateString(); - if (isYesterday) return `Last seen yesterday at ${time}`; - return `Last seen ${date.toLocaleDateString()} at ${time}`; + if (!dateString) return "Offline"; + const date = new Date(dateString); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const time = date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + if (isToday) return `Last seen today at ${time}`; + const nowCopy = new Date(); + const isYesterday = + new Date(nowCopy.setDate(nowCopy.getDate() - 1)).toDateString() === + date.toDateString(); + if (isYesterday) return `Last seen yesterday at ${time}`; + return `Last seen ${date.toLocaleDateString()} at ${time}`; }; // Main chat window with message list, input bar, and media features export default function ChatWindow({ selectedUser, onBack, isMobileHidden }) { - const { - messages, getMessages, sendMessage, deleteMessage, addReaction, - isMessagesLoading, subscribeToMessages, unsubscribeFromMessages, - typingUsers, markMessagesAsSeen, - hasMore, isLoadingMore, loadMoreMessages, searchTextMessages - } = useChatStore() - const { authUser, onlineUsers } = useAuthStore() - const { startOutgoingCall } = useCallStore() - const bookmarks = useBookmarkStore((state) => state.bookmarks) - const addBookmark = useBookmarkStore((state) => state.addBookmark) - const removeBookmark = useBookmarkStore((state) => state.removeBookmark) - const pendingBookmarkTarget = useBookmarkStore((state) => state.pendingBookmarkTarget) - const clearPendingBookmarkTarget = useBookmarkStore((state) => state.clearPendingBookmarkTarget) - - const [text, setText] = useState("") - const [imagePreview, setImagePreview] = useState(null) - const [imageBase64, setImageBase64] = useState(null) - const [sending, setSending] = useState(false) - const [replyTo, setReplyTo] = useState(null) - const [showEmoji, setShowEmoji] = useState(false) - const [showScheduleModal, setShowScheduleModal] = useState(false) - const [quickReplies, setQuickReplies] = useState([]) - const [quickRepliesLoading, setQuickRepliesLoading] = useState(false) - const [latestIncomingMessageId, setLatestIncomingMessageId] = useState(null) - const [showSpamWarning, setShowSpamWarning] = useState(false) - const [showInsights, setShowInsights] = useState(false) - const [showPoll, setShowPoll] = useState(false) - const [showNotes, setShowNotes] = useState(false) - const [sharedNotes, setSharedNotes] = useState("") - const [showGallery, setShowGallery] = useState(false) - const [showNewChatModal, setShowNewChatModal] = useState(false); - - // Search state - const [searchOpen, setSearchOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState("") - const [searchResults, setSearchResults] = useState([]) - const [recentSearches, setRecentSearches] = useState([]) - const [processingImage, setProcessingImage] = useState(false); - - // Debounced search trigger - useEffect(() => { - if (!searchQuery.trim()) { - setSearchResults([]); - return; - } - const timer = setTimeout(async () => { - if (!selectedUser?._id) return; - const results = await searchTextMessages( - selectedUser._id, - searchQuery - ); + const { + messages, + getMessages, + sendMessage, + deleteMessage, + addReaction, + isMessagesLoading, + subscribeToMessages, + unsubscribeFromMessages, + typingUsers, + markMessagesAsSeen, + hasMore, + isLoadingMore, + loadMoreMessages, + searchTextMessages, + } = useChatStore(); + const { authUser, onlineUsers } = useAuthStore(); + const { startOutgoingCall } = useCallStore(); + const bookmarks = useBookmarkStore((state) => state.bookmarks); + const addBookmark = useBookmarkStore((state) => state.addBookmark); + const removeBookmark = useBookmarkStore((state) => state.removeBookmark); + const pendingBookmarkTarget = useBookmarkStore( + (state) => state.pendingBookmarkTarget, + ); + const clearPendingBookmarkTarget = useBookmarkStore( + (state) => state.clearPendingBookmarkTarget, + ); + + const [text, setText] = useState(""); + const [imagePreview, setImagePreview] = useState(null); + const [imageBase64, setImageBase64] = useState(null); + const [sending, setSending] = useState(false); + const [replyTo, setReplyTo] = useState(null); + const [showEmoji, setShowEmoji] = useState(false); + const [showScheduleModal, setShowScheduleModal] = useState(false); + const [quickReplies, setQuickReplies] = useState([]); + const [quickRepliesLoading, setQuickRepliesLoading] = useState(false); + const [latestIncomingMessageId, setLatestIncomingMessageId] = useState(null); + const [showSpamWarning, setShowSpamWarning] = useState(false); + const [showInsights, setShowInsights] = useState(false); + const [showPoll, setShowPoll] = useState(false); + const [showNotes, setShowNotes] = useState(false); + const [sharedNotes, setSharedNotes] = useState(""); + const [showGallery, setShowGallery] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(null); + const [showNewChatModal, setShowNewChatModal] = useState(false); + + // Search state + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [recentSearches, setRecentSearches] = useState([]); + const [processingImage, setProcessingImage] = useState(false); + + // Debounced search trigger + useEffect(() => { + if (!searchQuery.trim()) { + setSearchResults([]); + return; + } + const timer = setTimeout(async () => { + if (!selectedUser?._id) return; + const results = await searchTextMessages(selectedUser._id, searchQuery); - setSearchResults(results || []); + setSearchResults(results || []); - const updatedSearches = [ + const updatedSearches = [ searchQuery, - ...recentSearches.filter( - item => item !== searchQuery - ) - ].slice(0, 5); + ...recentSearches.filter((item) => item !== searchQuery), + ].slice(0, 5); - setRecentSearches(updatedSearches); + setRecentSearches(updatedSearches); - localStorage.setItem( - "recentSearches", - JSON.stringify(updatedSearches) - ); -}, 300); - return () => clearTimeout(timer); - }, [searchQuery, selectedUser?._id]); - useEffect(() => { + localStorage.setItem("recentSearches", JSON.stringify(updatedSearches)); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery, selectedUser?._id]); + useEffect(() => { const savedSearches = JSON.parse( - localStorage.getItem("recentSearches") || "[]" + localStorage.getItem("recentSearches") || "[]", ); setRecentSearches(savedSearches); -}, []); - - const scrollToMessage = (msgId) => { - const msgElement = document.getElementById(`msg-${msgId}`); - if (msgElement) { - msgElement.scrollIntoView({ behavior: "smooth", block: "center" }); - const bubble = msgElement.querySelector(".chat-bubble"); - if (bubble) { - bubble.classList.add("flash-highlight"); - setTimeout(() => { - bubble.classList.remove("flash-highlight"); - }, 1500); - } - } else { - toast.error("Message is not loaded in current view. Scroll up to load older messages."); - } + }, []); + + const scrollToMessage = (msgId) => { + const msgElement = document.getElementById(`msg-${msgId}`); + if (msgElement) { + msgElement.scrollIntoView({ behavior: "smooth", block: "center" }); + const bubble = msgElement.querySelector(".chat-bubble"); + if (bubble) { + bubble.classList.add("flash-highlight"); + setTimeout(() => { + bubble.classList.remove("flash-highlight"); + }, 1500); + } + } else { + toast.error( + "Message is not loaded in current view. Scroll up to load older messages.", + ); } - - // Custom hooks - const { isRecording, recordingTime, audioBase64, startRecording, stopRecording, cancelRecording, clearAudio } = useRecording() - const { emitTyping } = useTypingIndicator(selectedUser?._id) - const { menu: contextMenu, openMenu, openMenuTouch, cancelTouch, closeMenu } = useContextMenu() - - const bottomRef = useRef(null) - const fileRef = useRef(null) - const textareaRef = useRef(null) - - useEffect(() => { - if (selectedUser?._id) { - getMessages(selectedUser._id) - subscribeToMessages() - } - return () => unsubscribeFromMessages() - }, [selectedUser, getMessages, subscribeToMessages, unsubscribeFromMessages]) - - useEffect(() => { - if (!pendingBookmarkTarget || !selectedUser || messages.length === 0) return - if (selectedUser._id !== pendingBookmarkTarget.chatId) return - scrollToMessage(pendingBookmarkTarget.messageId) - clearPendingBookmarkTarget() - }, [pendingBookmarkTarget, selectedUser, messages.length, clearPendingBookmarkTarget]) - - useEffect(() => { - if (selectedUser?._id && messages.length > 0) { - const hasUnseen = messages.some(m => m.senderId === selectedUser._id && m.status !== "seen"); - if (hasUnseen) markMessagesAsSeen(selectedUser._id); - } - }, [selectedUser?._id, messages.length]); - - useEffect(() => { - const lastMessage = messages[messages.length - 1]; - - if (!selectedUser?._id || !lastMessage || lastMessage.senderId !== selectedUser._id) { - setQuickReplies([]) - setLatestIncomingMessageId(null) - setQuickRepliesLoading(false) - return - } - - if (lastMessage._id === latestIncomingMessageId) return - - const loadSuggestions = async () => { - setQuickRepliesLoading(true) - try { - const res = await axiosInstance.get(`/messages/suggestions/${lastMessage._id}`) - setQuickReplies(res.data.suggestions || []) - } catch (error) { - setQuickReplies([]) - } finally { - setQuickRepliesLoading(false) - setLatestIncomingMessageId(lastMessage._id) - } - } - - loadSuggestions() - }, [messages, selectedUser?._id, latestIncomingMessageId]) - - const handleSendQuickReply = async (replyText) => { - if (!replyText.trim()) return - - setQuickReplies([]) - setText(replyText) - setSending(true) - - await sendMessage({ message: replyText, image: "", audio: "", replyTo: null }) - - setText("") - setSending(false) + }; + + // Custom hooks + const { + isRecording, + recordingTime, + audioBase64, + startRecording, + stopRecording, + cancelRecording, + clearAudio, + } = useRecording(); + const { emitTyping } = useTypingIndicator(selectedUser?._id); + const { + menu: contextMenu, + openMenu, + openMenuTouch, + cancelTouch, + closeMenu, + } = useContextMenu(); + + const bottomRef = useRef(null); + const fileRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + if (selectedUser?._id) { + getMessages(selectedUser._id); + subscribeToMessages(); + } + return () => unsubscribeFromMessages(); + }, [selectedUser, getMessages, subscribeToMessages, unsubscribeFromMessages]); + + useEffect(() => { + if (!pendingBookmarkTarget || !selectedUser || messages.length === 0) + return; + if (selectedUser._id !== pendingBookmarkTarget.chatId) return; + scrollToMessage(pendingBookmarkTarget.messageId); + clearPendingBookmarkTarget(); + }, [ + pendingBookmarkTarget, + selectedUser, + messages.length, + clearPendingBookmarkTarget, + ]); + + useEffect(() => { + if (selectedUser?._id && messages.length > 0) { + const hasUnseen = messages.some( + (m) => m.senderId === selectedUser._id && m.status !== "seen", + ); + if (hasUnseen) markMessagesAsSeen(selectedUser._id); + } + }, [selectedUser?._id, messages.length]); + + useEffect(() => { + const lastMessage = messages[messages.length - 1]; + + if ( + !selectedUser?._id || + !lastMessage || + lastMessage.senderId !== selectedUser._id + ) { + setQuickReplies([]); + setLatestIncomingMessageId(null); + setQuickRepliesLoading(false); + return; } - const chatContainerRef = useRef(null) - const prevMessagesRef = useRef([]) - const prevScrollHeightRef = useRef(0) - const prevScrollTopRef = useRef(0) - - useLayoutEffect(() => { - const el = chatContainerRef.current - if (!el) return - - const prevMessages = prevMessagesRef.current - const currentMessages = messages - prevMessagesRef.current = messages - - // Initial load of messages for selected user - if (prevMessages.length === 0 && currentMessages.length > 0) { - el.scrollTop = el.scrollHeight - return - } - - // Check if messages were prepended (loaded older messages) - const wasPrepended = - prevMessages.length > 0 && - currentMessages.length > prevMessages.length && - currentMessages[currentMessages.length - 1]?._id === prevMessages[prevMessages.length - 1]?._id && - currentMessages[0]?._id !== prevMessages[0]?._id - - if (wasPrepended) { - const heightDifference = el.scrollHeight - prevScrollHeightRef.current - el.scrollTop = prevScrollTopRef.current + heightDifference - return - } - - // Check if messages were appended (new message) - const wasAppended = - prevMessages.length > 0 && - currentMessages.length > prevMessages.length && - currentMessages[0]?._id === prevMessages[0]?._id && - currentMessages[currentMessages.length - 1]?._id !== prevMessages[prevMessages.length - 1]?._id - - if (wasAppended) { - const lastMsg = currentMessages[currentMessages.length - 1] - const isMyMsg = lastMsg.senderId === authUser?._id - - // Scroll to bottom if it was our message, or if user is already near the bottom - const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200 - if (isMyMsg || isNearBottom) { - el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }) - } - } - }, [messages, authUser?._id]) - - // Scroll handler for loading older messages - const handleScroll = useCallback(() => { - const el = chatContainerRef.current - if (!el || !hasMore || isLoadingMore) return - if (el.scrollTop < 80) { - prevScrollHeightRef.current = el.scrollHeight - prevScrollTopRef.current = el.scrollTop - loadMoreMessages(selectedUser._id) - } - }, [hasMore, isLoadingMore, selectedUser, loadMoreMessages]) - - const handleBookmark = () => { - const message = contextMenu.message - if (!message) return - - const currentBookmark = bookmarks.some((item) => item.id === message._id) - if (currentBookmark) { - removeBookmark(message._id) - toast.success("Bookmark removed") - } else { - const chatId = message.senderId === authUser._id ? message.receiverId : message.senderId - addBookmark({ - id: message._id, - chatId, - senderId: message.senderId, - senderName: message.senderId === authUser._id ? authUser.name : selectedUser?.name || "Unknown", - content: message.message || "", - image: message.image || null, - audio: message.audio || null, - createdAt: message.createdAt, - bookmarkedAt: new Date().toISOString(), - }) - toast.success("Message bookmarked") - } - closeMenu() + if (lastMessage._id === latestIncomingMessageId) return; + + const loadSuggestions = async () => { + setQuickRepliesLoading(true); + try { + const res = await axiosInstance.get( + `/messages/suggestions/${lastMessage._id}`, + ); + setQuickReplies(res.data.suggestions || []); + } catch (error) { + setQuickReplies([]); + } finally { + setQuickRepliesLoading(false); + setLatestIncomingMessageId(lastMessage._id); + } + }; + + loadSuggestions(); + }, [messages, selectedUser?._id, latestIncomingMessageId]); + + const handleSendQuickReply = async (replyText) => { + if (!replyText.trim()) return; + + setQuickReplies([]); + setText(replyText); + setSending(true); + + await sendMessage({ + message: replyText, + image: "", + audio: "", + replyTo: null, + }); + + setText(""); + setSending(false); + }; + + const chatContainerRef = useRef(null); + const prevMessagesRef = useRef([]); + const prevScrollHeightRef = useRef(0); + const prevScrollTopRef = useRef(0); + + useLayoutEffect(() => { + const el = chatContainerRef.current; + if (!el) return; + + const prevMessages = prevMessagesRef.current; + const currentMessages = messages; + prevMessagesRef.current = messages; + + // Initial load of messages for selected user + if (prevMessages.length === 0 && currentMessages.length > 0) { + el.scrollTop = el.scrollHeight; + return; } - const handleReply = () => { setReplyTo(contextMenu.message); closeMenu() } - const handleCopy = () => { - if (contextMenu.message?.message) { - navigator.clipboard.writeText(contextMenu.message.message) - toast.success("Copied!") - } - closeMenu() + // Check if messages were prepended (loaded older messages) + const wasPrepended = + prevMessages.length > 0 && + currentMessages.length > prevMessages.length && + currentMessages[currentMessages.length - 1]?._id === + prevMessages[prevMessages.length - 1]?._id && + currentMessages[0]?._id !== prevMessages[0]?._id; + + if (wasPrepended) { + const heightDifference = el.scrollHeight - prevScrollHeightRef.current; + el.scrollTop = prevScrollTopRef.current + heightDifference; + return; } - const handleDelete = async () => { await deleteMessage(contextMenu.message._id); closeMenu() } - const handleReact = (messageId, emoji) => { addReaction(messageId, emoji) } -const handleImage = async (e) => { + // Check if messages were appended (new message) + const wasAppended = + prevMessages.length > 0 && + currentMessages.length > prevMessages.length && + currentMessages[0]?._id === prevMessages[0]?._id && + currentMessages[currentMessages.length - 1]?._id !== + prevMessages[prevMessages.length - 1]?._id; + + if (wasAppended) { + const lastMsg = currentMessages[currentMessages.length - 1]; + const isMyMsg = lastMsg.senderId === authUser?._id; + + // Scroll to bottom if it was our message, or if user is already near the bottom + const isNearBottom = + el.scrollHeight - el.scrollTop - el.clientHeight < 200; + if (isMyMsg || isNearBottom) { + el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + } + } + }, [messages, authUser?._id]); + + // Scroll handler for loading older messages + const handleScroll = useCallback(() => { + const el = chatContainerRef.current; + if (!el || !hasMore || isLoadingMore) return; + if (el.scrollTop < 80) { + prevScrollHeightRef.current = el.scrollHeight; + prevScrollTopRef.current = el.scrollTop; + loadMoreMessages(selectedUser._id); + } + }, [hasMore, isLoadingMore, selectedUser, loadMoreMessages]); + + const handleBookmark = () => { + const message = contextMenu.message; + if (!message) return; + + const currentBookmark = bookmarks.some((item) => item.id === message._id); + if (currentBookmark) { + removeBookmark(message._id); + toast.success("Bookmark removed"); + } else { + const chatId = + message.senderId === authUser._id + ? message.receiverId + : message.senderId; + addBookmark({ + id: message._id, + chatId, + senderId: message.senderId, + senderName: + message.senderId === authUser._id + ? authUser.name + : selectedUser?.name || "Unknown", + content: message.message || "", + image: message.image || null, + audio: message.audio || null, + createdAt: message.createdAt, + bookmarkedAt: new Date().toISOString(), + }); + toast.success("Message bookmarked"); + } + closeMenu(); + }; + + const handleReply = () => { + setReplyTo(contextMenu.message); + closeMenu(); + }; + const handleCopy = () => { + if (contextMenu.message?.message) { + navigator.clipboard.writeText(contextMenu.message.message); + toast.success("Copied!"); + } + closeMenu(); + }; + const handleDelete = async () => { + await deleteMessage(contextMenu.message._id); + closeMenu(); + }; + const handleReact = (messageId, emoji) => { + addReaction(messageId, emoji); + }; + + const handleImage = async (e) => { const file = e.target.files?.[0]; if (!file) return; const MAX_SIZE = 5 * 1024 * 1024; // 5MB if (file.size > MAX_SIZE) { - toast.error("Image must be smaller than 5MB"); - return; + toast.error("Image must be smaller than 5MB"); + return; } try { - setProcessingImage(true); - - const compressedFile = await imageCompression(file, { - maxSizeMB: 1, - maxWidthOrHeight: 1920, - useWebWorker: true, - }); + setProcessingImage(true); - const reader = new FileReader(); + const compressedFile = await imageCompression(file, { + maxSizeMB: 1, + maxWidthOrHeight: 1920, + useWebWorker: true, + }); - reader.onloadend = () => { - setImagePreview(URL.createObjectURL(compressedFile)); - setImageBase64(reader.result); - setProcessingImage(false); - }; + const reader = new FileReader(); - reader.onerror = () => { - setProcessingImage(false); - toast.error("Failed to read image"); - }; + reader.onloadend = () => { + setImagePreview(URL.createObjectURL(compressedFile)); + setImageBase64(reader.result); + setProcessingImage(false); + }; - reader.readAsDataURL(compressedFile); + reader.onerror = () => { + setProcessingImage(false); + toast.error("Failed to read image"); + }; + reader.readAsDataURL(compressedFile); } catch (error) { - console.error(error); - setProcessingImage(false); - toast.error("Failed to process image"); + console.error(error); + setProcessingImage(false); + toast.error("Failed to process image"); } -}; + }; + + const handleSend = async () => { + if (!text.trim() && !imageBase64 && !audioBase64) return; + + const payload = { + message: text.trim(), + image: imageBase64 || "", + audio: audioBase64 || "", + replyTo: replyTo + ? { + _id: replyTo._id, + message: replyTo.message, + senderName: + replyTo.senderId === authUser._id + ? authUser.name + : selectedUser.name, + } + : null, + }; + + setSending(true); + setShowEmoji(false); + setText(""); + setImagePreview(null); + setImageBase64(null); + clearAudio(); + setReplyTo(null); - const handleSend = async () => { - if (!text.trim() && !imageBase64 && !audioBase64) return - - const payload = { - message: text.trim(), - image: imageBase64 || "", - audio: audioBase64 || "", - replyTo: replyTo ? { - _id: replyTo._id, - message: replyTo.message, - senderName: replyTo.senderId === authUser._id ? authUser.name : selectedUser.name, - } : null, - } - - setSending(true) - setShowEmoji(false) - setText("") - setImagePreview(null) - setImageBase64(null) - clearAudio() - setReplyTo(null) - - try { - await sendMessage(payload) -} catch (err) { - toast.error("Failed to send") -} finally { - setSending(false) -} + try { + await sendMessage(payload); + } catch (err) { + toast.error("Failed to send"); + } finally { + setSending(false); } + }; - const handleTyping = (e) => { - const value = e.target.value -setText(value) - -const suspiciousWords = ["spam", "scam", "fake", "hack"] - -setShowSpamWarning( - suspiciousWords.some(word => - value.toLowerCase().includes(word) - ) -) - // Auto-resize textarea up to ~4 lines - const ta = textareaRef.current - if (ta) { - ta.style.height = "auto" - ta.style.height = Math.min(ta.scrollHeight, 120) + "px" - } - emitTyping() + const handleTyping = (e) => { + const value = e.target.value; + setText(value); + + const suspiciousWords = ["spam", "scam", "fake", "hack"]; + + setShowSpamWarning( + suspiciousWords.some((word) => value.toLowerCase().includes(word)), + ); + // Auto-resize textarea up to ~4 lines + const ta = textareaRef.current; + if (ta) { + ta.style.height = "auto"; + ta.style.height = Math.min(ta.scrollHeight, 120) + "px"; } + emitTyping(); + }; - const handleKeyDown = (e) => { - if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend() } + const handleKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); } + }; - const isOnline = selectedUser && onlineUsers.includes(selectedUser._id) - const totalMessages = messages.length + const isOnline = selectedUser && onlineUsers.includes(selectedUser._id); + const totalMessages = messages.length; -const myMessages = messages.filter( - msg => msg.senderId === authUser?._id -).length + const myMessages = messages.filter( + (msg) => msg.senderId === authUser?._id, + ).length; -const receivedMessages = - totalMessages - myMessages + const receivedMessages = totalMessages - myMessages; -const mediaMessages = messages.filter( - msg => msg.image || msg.audio -).length - const sharedMedia = messages.filter(msg => msg.image) + const mediaMessages = messages.filter((msg) => msg.image || msg.audio).length; + const sharedMedia = messages.filter((msg) => msg.image); - if (!selectedUser) return ( - <> -
-
- + if (!selectedUser) + return ( + <> +
+
{/* --- TOP STATUS BAR --- */}
-
- - SESSION • NEW -
- {/* Fixed the crash by using a safe optional check or inline handler */} - +
+ + SESSION • NEW +
+ {/* Fixed the crash by using a safe optional check or inline handler */} +
{/* --- CENTER HERO SECTION --- */}
- - {/* Glow & Spark Logo */} -
-
-
- -
+ {/* Glow & Spark Logo */} +
+
+
+
- - {/* Text Headers */} -
-

- — LUMEN • YOUR EVENING COMPANION — -

- -

- Late night thoughts? -

-

- What's on your mind? -

- -

- A quiet place to think out loud, draft something good, or sketch the next idea —{" "} - without the noise. +

+ + {/* Text Headers */} +
+

+ — LUMEN • YOUR EVENING COMPANION — +

+ +

+ Late night thoughts? +

+

+ What's on your mind? +

+ +

+ A quiet place to think out loud, draft something good, or + sketch the next idea —{" "} + + without the noise. + +

+
+ + {/* --- GRID SUGGESTIONS CARDS --- */} +
+ {/* Card 1: WRITE */} +
+
+ +
+
+ + WRITE + +

+ Draft a heartfelt thank-you note +

+

+ for a mentor who changed my path

+
- {/* --- GRID SUGGESTIONS CARDS --- */} -
- - {/* Card 1: WRITE */} -
-
- -
-
- - WRITE - -

- Draft a heartfelt thank-you note -

-

- for a mentor who changed my path -

-
-
- - {/* Card 2: PLAN */} -
-
- -
-
- - PLAN - -

- Design a 3-day Lisbon itinerary -

-

- slow mornings, golden hour walks -

-
-
- + {/* Card 2: PLAN */} +
+
+ +
+
+ + PLAN + +

+ Design a 3-day Lisbon itinerary +

+

+ slow mornings, golden hour walks +

+
- +
+
-
- {showNewChatModal && ( - setShowNewChatModal(false)} - onSelectUser={(user) => { - // open selected chat - console.log(user); - }} - /> -)} - -) + {showNewChatModal && ( + setShowNewChatModal(false)} + onSelectUser={(user) => { + // open selected chat + console.log(user); + }} + /> + )} + + ); - return ( -
-
+ return ( +
+
+ + +
+

{selectedUser.name}

+ {selectedUser.statusMood ? ( +

+ {getStatusMoodLabel(selectedUser.statusMood)} +

+ ) : null} +

+ {typingUsers.includes(selectedUser._id) ? ( + + typing... + + ) : isOnline ? ( + "Online" + ) : ( + formatLastSeen(selectedUser.lastSeen) + )} +

+
+
+ + + + + + + + + +
+
+ + {searchOpen && ( +
+
+ setSearchQuery(e.target.value)} + className="input input-sm input-bordered flex-1 focus:outline-none" + autoFocus + /> + +
+ {searchResults.length > 0 && ( +
+

+ {searchResults.length} results found: +

+ {searchResults.map((res) => ( - -
-

{selectedUser.name}

- {selectedUser.statusMood ? ( -

- {getStatusMoodLabel(selectedUser.statusMood)} -

- ) : null} -

- {typingUsers.includes(selectedUser._id) ? ( - typing... - ) : isOnline ? ( - "Online" - ) : ( - formatLastSeen(selectedUser.lastSeen) - )} -

-
-
- - - - - - - - - -
+ ))}
- - {searchOpen && ( -
-
- setSearchQuery(e.target.value)} - className="input input-sm input-bordered flex-1 focus:outline-none" - autoFocus - /> - -
- {searchResults.length > 0 && ( -
-

{searchResults.length} results found:

- {searchResults.map((res) => ( - - ))} -
- )} - {searchQuery && searchResults.length === 0 && ( -
- No results found -
- )} -
- )} - - {showGallery && ( -
-

- Shared Media Gallery -

- -
- {sharedMedia.map(media => ( - shared media - window.open(media.image, "_blank") - } - /> - ))} + )} + {searchQuery && searchResults.length === 0 && ( +
+ No results found +
+ )}
+ )} + + {showGallery && ( +
+

Shared Media Gallery

+ +
+ {sharedMedia.map((media, index) => ( + shared media setLightboxIndex(index)} + /> + ))} +
- {sharedMedia.length === 0 && ( + {sharedMedia.length === 0 && (

- No shared media found + No shared media found

- )} -
-)} - -
- {showNotes && ( -
-
-

- Collaborative Notes -

- - - Shared - -
- -