Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/Database/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const connectDB = async () => {
if (!mongoUrl) {
throw new Error("MONGODB_URI or MONGO_URL not found in environment variables");
}

await mongoose.connect(mongoUrl);
console.log(`MongoDB connected successfully ${mongoose.connection.host}`);
} catch (err) {
Expand Down
4 changes: 3 additions & 1 deletion backend/src/routes/message.route.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ const {
getUnreadCount
} = require('../controllers/message.controller');

const { protectRoute } = require('../middleware/auth.middleware');

// Send message
router.post('/send', sendMessage);
router.post('/send', protectRoute, sendMessage);

// Get messages with a user
// router.get('/:userId', getMessages);
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/pages/chat/components/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { useState } from "react";
const MessageInput = () => {
const [newMessage, setNewMessage] = useState("");
const { user } = useUser();
const { selectedUser, sendMessge } = useChatStore();
const { selectedUser, sendMessage } = useChatStore();

const handleSend = () => {
if (!selectedUser || !user || !newMessage) return;
sendMessge(selectedUser.clerkId, user.id, newMessage.trim());
const handleSend = async () => {
if (!selectedUser || !user || !newMessage.trim()) return;
await sendMessage(selectedUser.clerkId, newMessage.trim());
setNewMessage("");
};

Expand Down
34 changes: 20 additions & 14 deletions frontend/src/pages/chat/page/ChatPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@

import { useChatStore } from "@/store/useChatStore";
import { useUser } from "@clerk/clerk-react";
import { useEffect } from "react";
import UsersList from "../components/UsersList.jsx";
import { useEffect, useRef } from "react";
import UsersList from "../components/UsersList";
import ChatHeader from "../components/ChatHeader";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import MessageInput from "../components/MessageInput";
// import TopHeader from "../../../components/layout/components/TopHeader";

const formatTime = (date: string | number | Date) => {
return new Date(date).toLocaleTimeString("en-US", {
Expand All @@ -19,32 +17,38 @@ const formatTime = (date: string | number | Date) => {

const ChatPage = () => {
const { user } = useUser();
const { messages, selectedUser, fetchUsers, fetchMessages } = useChatStore();
const { messages, selectedUser, fetchUsers, fetchMessages, startPolling, stopPolling } = useChatStore();
const scrollRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (user) fetchUsers();
}, [fetchUsers, user]);

useEffect(() => {
if (selectedUser) fetchMessages(selectedUser.clerkId);
}, [selectedUser, fetchMessages]);
if (selectedUser) {
fetchMessages(selectedUser.clerkId);
startPolling(selectedUser.clerkId);
}

return () => stopPolling();
}, [selectedUser, fetchMessages, startPolling, stopPolling]);

// console.log({ messages });
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);

return (
<main className='h-full rounded-lg bg-linear-to-b from-zinc-800 to-zinc-900 overflow-hidden'>
{/* <TopHeader/> */}

<div className='grid lg:grid-cols-[300px_1fr] grid-cols-[80px_1fr] h-[calc(100vh-180px)]'>
<UsersList />

{/* chat message */}
<div className='flex flex-col h-full'>
{selectedUser ? (
<>
<ChatHeader />

{/* Messages */}
<ScrollArea className='h-[calc(100vh-340px)]'>
<div className='p-4 space-y-4'>
{messages?.map((message) => (
Expand Down Expand Up @@ -76,6 +80,7 @@ const ChatPage = () => {
</div>
</div>
))}
<div ref={scrollRef} />
</div>
</ScrollArea>

Expand All @@ -89,7 +94,6 @@ const ChatPage = () => {
</main>
);
};
export default ChatPage;

const NoConversationPlaceholder = () => (
<div className='flex flex-col items-center justify-center h-full space-y-6'>
Expand All @@ -99,4 +103,6 @@ const NoConversationPlaceholder = () => (
<p className='text-zinc-500 text-sm'>Choose a friend to start chatting</p>
</div>
</div>
);
);

export default ChatPage;
173 changes: 98 additions & 75 deletions frontend/src/store/useChatStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { create } from "zustand";
import { axiosInstance } from "../lib/axios";
import type { Message, User } from "@/types";
import { io, type Socket } from "socket.io-client";
import toast from "react-hot-toast";

interface chatStore {
users: User[];
Expand All @@ -13,20 +14,23 @@ interface chatStore {
userActivities: Map<string, string>;
messages: Message[];
selectedUser: User | null;
pollingInterval: number | null;

fetchUsers: () => Promise<void>;
initSocket: (userId: string) => void;
disconnectSocket: () => void;
sendMessge: (receiverId: string, senderId: string, content: string) => void;
sendMessage: (receiverId: string, content: string) => Promise<void>;
fetchMessages: (userId: string) => Promise<void>;
setSelectedUser: (user: User | null) => void;
startPolling: (userId: string) => void;
stopPolling: () => void;
}

const baseURL =
import.meta.env.MODE === "development"
? "http://localhost:5000"
: "https://music-bd.vercel.app";
// const baseURL = "https://music-bd.vercel.app";

const socket = io(baseURL, {
autoConnect: false,
withCredentials: true,
Expand All @@ -42,49 +46,34 @@ export const useChatStore = create<chatStore>((set, get) => ({
userActivities: new Map(),
messages: [],
selectedUser: null,
pollingInterval: null,

setSelectedUser: (user) => set({ selectedUser: user }),

fetchUsers: async () => {
set({ isLoading: true, error: null });
try {
await axiosInstance.get("/users").then((res) => {
const response = res.data.user;
set({ users: response });
// console.log(response);
});
} catch (error) {
let message = "Error fetching users";
try {
const e = JSON.parse(JSON.stringify(error));
if (e?.response?.data?.message) message = e.response.data.message;
else if (e?.message) message = e.message;
} catch {
// keep generic
}
set({ error: message });
const res = await axiosInstance.get("/users");
set({ users: res.data.user });
} catch (error: unknown) {
const errorMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || "Error fetching users";
set({ error: errorMessage });
} finally {
set({ isLoading: false });
}
},

initSocket: (userId) => {
if (!get().isConnected) {
socket.auth = { userId };
socket.connect();

// Clear existing listeners to prevent duplicates
socket.off("user_online");
socket.off("activities");
socket.off("user_connected");
socket.off("user_disconnected");
socket.off("receive_message");
socket.off("message_sent");
socket.off("activity_updated");
// Clear existing listeners
const events = ["user_online", "activities", "user_connected", "user_disconnected", "receive_message", "message_sent", "activity_updated"];
events.forEach(event => socket.off(event));

socket.emit("user_connected", userId);

// store socket instance in state so other actions can use it
set({ socket });
set({ socket, isConnected: true });

socket.on("user_online", (users: string[]) => {
set({ onlineUsers: new Set(users) });
Expand All @@ -94,89 +83,123 @@ export const useChatStore = create<chatStore>((set, get) => ({
set({ userActivities: new Map(activities) });
});

socket.on("user_connected", (userId: string) => {
socket.on("user_connected", (id: string) => {
set((state) => ({
onlineUsers: new Set([...state.onlineUsers, userId]),
onlineUsers: new Set([...state.onlineUsers, id]),
}));
});

socket.on("user_disconnected", (userId: string) => {
socket.on("user_disconnected", (id: string) => {
set((state) => {
const newOnlineUsers = new Set(state.onlineUsers);
newOnlineUsers.delete(userId);
newOnlineUsers.delete(id);
return { onlineUsers: newOnlineUsers };
});
});

socket.on("receive_message", (message: Message) => {
set((state) => ({
messages: [...state.messages, message],
}));
});
const handleNewMessage = (message: Message) => {
set((state) => {
if (state.messages.some(m => m._id === message._id)) return state;
const newMessages = [...state.messages, message].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
return { messages: newMessages };
});
};

socket.on("message_sent", (message: Message) => {
set((state) => ({
messages: [...state.messages, message],
}));
});
socket.on("receive_message", handleNewMessage);
socket.on("message_sent", handleNewMessage);

socket.on("activity_updated", ({ userId, activity }) => {
// console.log('Activity updated:', { userId, activity });
socket.on("activity_updated", ({ userId: id, activity }) => {
set((state) => {
const newActivities = new Map(state.userActivities);
newActivities.set(userId, activity);
// console.log('New activities map:', Array.from(newActivities.entries()));
newActivities.set(id, activity);
return { userActivities: newActivities };
});
});

set({ isConnected: true });
}
},

disconnectSocket: () => {
if (get().isConnected) {
socket.off("user_online");
socket.off("activities");
socket.off("user_connected");
socket.off("user_disconnected");
socket.off("receive_message");
socket.off("message_sent");
socket.off("activity_updated");

socket.disconnect();
set({ isConnected: false, socket: null });
}
get().stopPolling();
},
sendMessge: (receiverId, senderId, content) => {
// prefer using the stored socket instance
const sock = get().socket || socket;
if (!sock) return;

sock.emit("send_message", { receiverId, senderId, content });
sendMessage: async (receiverId, content) => {
if (!content.trim()) return;

try {
const response = await axiosInstance.post("/messages/send", {
receiverId,
content: content.trim(),
});

if (response.data.success) {
const newMessage = response.data.data;
set((state) => {
if (state.messages.some(m => m._id === newMessage._id)) return state;
const newMessages = [...state.messages, newMessage].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
return { messages: newMessages };
});

const sock = get().socket;
if (sock && sock.connected) {
sock.emit("send_message", { receiverId, content });
}
}
} catch (error: unknown) {
const errorMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to send message";
toast.error(errorMessage);
}
},

fetchMessages: async (userId: string) => {
set({ isLoading: true, error: null });
const isFirstFetch = get().messages.length === 0;
if (isFirstFetch) set({ isLoading: true, error: null });

try {
const response = await axiosInstance.get(`/users/message/${userId}`);
// backend returns an array directly (res.json(messages)),
// but some endpoints may return { messages: [...] }.
const messagesPayload = Array.isArray(response.data)
? response.data
: response.data?.messages ?? [];
set({ messages: messagesPayload });
} catch (error) {
let message = "Error fetching messages";
try {
const e = JSON.parse(JSON.stringify(error));
if (e?.response?.data?.message) message = e.response.data.message;
else if (e?.message) message = e.message;
} catch {
// keep generic message

const sortedMessages = messagesPayload.sort(
(a: Message, b: Message) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);

if (JSON.stringify(sortedMessages) !== JSON.stringify(get().messages)) {
set({ messages: sortedMessages });
}
} catch (error: unknown) {
if (isFirstFetch) {
const errorMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || "Error fetching messages";
set({ error: errorMessage });
}
set({ error: message });
} finally {
set({ isLoading: false });
if (isFirstFetch) set({ isLoading: false });
}
},

startPolling: (userId) => {
get().stopPolling();
const interval = window.setInterval(() => {
if (!get().isConnected || !get().socket?.connected) {
get().fetchMessages(userId);
}
}, 4000);
set({ pollingInterval: interval });
},

stopPolling: () => {
const interval = get().pollingInterval;
if (interval !== null) {
window.clearInterval(interval);
set({ pollingInterval: null });
}
},
}));
Loading