diff --git a/package-lock.json b/package-lock.json index ccd7de5..d02f667 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.38.0", + "jwt-decode": "^4.0.0", "lucide-react": "^1.8.0", "next": "16.2.4", "radix-ui": "^1.4.3", @@ -8053,6 +8054,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 8ec06da..45e4d37 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.38.0", + "jwt-decode": "^4.0.0", "lucide-react": "^1.8.0", "next": "16.2.4", "radix-ui": "^1.4.3", diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index faa0168..560a3fb 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; import { useSocket, type ChatMessage, type RoomNotice } from "@/context/SocketContext"; -import { fetchConversations, createConversation } from "@/lib/api"; +import { fetchConversations, createConversation, fetchAllUsers, ConversationType, addParticipant, removeParticipant } from "@/lib/api"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { @@ -18,6 +18,9 @@ import { Wifi, WifiOff, Users, + User as UserIcon, + UserMinus, + UserPlus, } from "lucide-react"; import { Avatar, @@ -26,22 +29,17 @@ import { } from "@/components/ui/avatar"; import { useDebounce } from "@/hooks/useDebounce"; -/* ------------------------------------------------------------------ */ -/* Types */ -/* ------------------------------------------------------------------ */ + + + interface Room { id: string; name: string; isGroup: boolean; + participants: { _id: string; name: string }[]; } -/* ------------------------------------------------------------------ */ -/* Backend rooms loaded dynamically */ -/* ------------------------------------------------------------------ */ -/* ------------------------------------------------------------------ */ -/* ChatPage */ -/* ------------------------------------------------------------------ */ export default function ChatPage() { const router = useRouter(); const { user, isLoading: authLoading, logout } = useAuth(); @@ -51,71 +49,143 @@ export default function ChatPage() { joinRoom, leaveRoom, sendMessage, + startP2P, messages, notices, currentRoom, setCurrentRoom, clearMessages, + p2pReady, + clearP2PReady, } = useSocket(); const [text, setText] = useState(""); - const [search, setSearch] = useState(""); - const [activeTab, setActiveTab] = useState<"rooms" | "create">("rooms"); + const [roomSearch, setRoomSearch] = useState(""); + const [userSearch, setUserSearch] = useState(""); + const [activeTab, setActiveTab] = useState<"rooms" | "users" | "create">("rooms"); const [newRoomName, setNewRoomName] = useState(""); const [rooms, setRooms] = useState([]); + const [allUsers, setAllUsers] = useState([]); - const debouncedSearch = useDebounce(search, 300); + const debouncedRoomSearch = useDebounce(roomSearch, 300); + const debouncedUserSearch = useDebounce(userSearch, 300); const messagesEndRef = useRef(null); + const [managingRoom, setManagingRoom] = useState(null); - // ── Auth guard ──────────────────────────────────────────────────── + // Auth guard useEffect(() => { if (!authLoading && !user) { router.push("/login"); } }, [authLoading, user, router]); - // ── Fetch rooms & Auto-join ───────────────────────────────────── + // Fetch rooms & Auto-join useEffect(() => { - if (!isConnected) return; - - // Fetch conversations from API - fetchConversations() + // Guard: wait for everything needed + if (!isConnected || !user?.userId || allUsers.length === 0) return; + + fetchConversations(user.userId) .then((data) => { - // Map backend returned data to Room format - const loadedRooms = Array.isArray(data) ? data.map(c => ({ - id: c._id || c.id || "unknown", - name: c.name || "Unnamed Group", - isGroup: c.type === "group" || c.isGroup || true - })) : []; - + const loadedRooms = Array.isArray(data) + ? data.map((c) => { + const isGroup = c.type === "group"; + + let roomName = c.name; + + // FIX: derive name for P2P + if (!isGroup) { + let otherUserName = "Unknown User"; + + // Case 1: participants are objects + if (typeof c.participants?.[0] === "object") { + const otherUser = c.participants.find( + (p: any) => p._id !== user.userId + ); + otherUserName = otherUser?.name || otherUserName; + } + // Case 2: participants are IDs + else { + const otherUserId = c.participants?.find( + (id: string) => id !== user.userId + ); + + const otherUser = allUsers.find( + (u) => u._id === otherUserId + ); + + otherUserName = otherUser?.name || otherUserName; + } + + roomName = otherUserName; + } + + return { + id: c._id || c.id, + name: roomName || "Unnamed Group", + isGroup, + participants: c.participants || [], + }; + }) + : []; + setRooms(loadedRooms); - - // Auto-join first room + + // Auto-join first room (only once) if (!currentRoom && loadedRooms.length > 0) { const firstRoom = loadedRooms[0]; setCurrentRoom(firstRoom.id); joinRoom(firstRoom.id); } }) - .catch(err => { - console.error("Failed to fetch conversations", err); + .catch((err) => { + console.error("Failed to fetch conversations", err.message); }); - }, [isConnected, currentRoom, joinRoom, setCurrentRoom]); + }, [isConnected, user, allUsers, currentRoom, joinRoom, setCurrentRoom]); - // ── Scroll to bottom on new messages ────────────────────────────── + // Fetch all users for P2P tab + useEffect(() => { + if (!user) return; + fetchAllUsers() + .then((data) => { + if (Array.isArray(data)) { + setAllUsers(data.filter((u: any) => u.name !== user.username)); + } + }) + .catch(console.error); + }, [user]); + + // ── React to P2P Ready event + useEffect(() => { + if (!p2pReady) return; + + const targetUser = allUsers.find((u) => u._id === p2pReady.targetUserId); + const roomName = targetUser?.name || "Direct Message"; + + // Add the new P2P room to sidebar if not already there + setRooms((prev) => { + if (prev.find((r) => r.id === p2pReady.roomId)) return prev; + return [...prev, { id: p2pReady.roomId, name: roomName, isGroup: false }]; + }); + + // Switch to rooms tab + setActiveTab("rooms"); + clearP2PReady(); + }, [p2pReady, allUsers, clearP2PReady]); + + // ── Scroll to bottom on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, notices]); - // ── Filtered rooms ──────────────────────────────────────────────── + // ── Filtered rooms const filteredRooms = useMemo(() => { - if (!debouncedSearch.trim()) return rooms; + if (!debouncedRoomSearch.trim()) return rooms; return rooms.filter((r) => - r.name.toLowerCase().includes(debouncedSearch.toLowerCase()) + r.name.toLowerCase().includes(debouncedRoomSearch.toLowerCase()) ); - }, [rooms, debouncedSearch]); + }, [rooms, debouncedRoomSearch]); - // ── Messages for current room ───────────────────────────────────── + // ── Messages for current room const roomMessages = useMemo( () => messages.filter((m) => m.roomId === currentRoom), [messages, currentRoom] @@ -126,7 +196,7 @@ export default function ChatPage() { [notices, currentRoom] ); - // ── Handlers ────────────────────────────────────────────────────── + // ── Handlers const handleSelectRoom = (room: Room) => { if (currentRoom === room.id) return; @@ -155,20 +225,20 @@ export default function ChatPage() { const handleCreateRoom = async () => { const name = newRoomName.trim(); if (!name) return; - + console.log(user); try { - // Actually create the room on the backend - const created = await createConversation({ name, type: "group", participants: [user?.username || "unknown"] }); - const newRoom: Room = { - id: created._id || created.id || name.toLowerCase().replace(/\s+/g, "-"), - name: created.name || name, - isGroup: true - }; - - setRooms((prev) => { - if (prev.find((r) => r.id === newRoom.id)) return prev; - return [...prev, newRoom]; + const created = await createConversation({ + name, + type: ConversationType.GROUP, + participants: [user!.userId], // send userId not username }); + const newRoom: Room = { + id: created._id || created.id, + name: created.name || name, + isGroup: true, + participants: created.participants || [], + }; + setRooms(prev => prev.find(r => r.id === newRoom.id) ? prev : [...prev, newRoom]); setNewRoomName(""); setActiveTab("rooms"); handleSelectRoom(newRoom); @@ -177,6 +247,42 @@ export default function ChatPage() { } }; + + const handleAddParticipant = async (convId: string, userId: string) => { + try { + const updated = await addParticipant(convId, userId); + setRooms(prev => prev.map(r => + r.id === convId + ? { ...r, participants: updated.participants || r.participants } + : r + )); + // refresh managingRoom state + setManagingRoom(prev => prev?.id === convId ? { ...prev, participants: updated.participants || prev.participants } : prev); + } catch (err: any) { + console.error("Add participant failed:", err.message); + } + }; + + const handleRemoveParticipant = async (convId: string, userId: string) => { + try { + const updated = await removeParticipant(convId, userId); + setRooms(prev => prev.map(r => + r.id === convId + ? { ...r, participants: updated.participants || r.participants } + : r + )); + setManagingRoom(prev => prev?.id === convId ? { ...prev, participants: updated.participants || prev.participants } : prev); + } catch (err: any) { + console.error("Remove participant failed:", err.message); + } + }; + + + const handleStartP2P = (targetUser: any) => { + if (!targetUser?._id) return; + startP2P(targetUser._id); + }; + const handleLogout = () => { if (currentRoom) leaveRoom(currentRoom); clearMessages(); @@ -237,26 +343,33 @@ export default function ChatPage() { {/* TABS */} -
+
+
@@ -281,64 +394,213 @@ export default function ChatPage() {
)} - {/* SEARCH */} -
- setSearch(e.target.value)} - className="rounded-full bg-muted" - /> -
- {/* ROOM LIST */} -
- {filteredRooms.length === 0 && ( -

- No rooms found -

- )} + {activeTab === "rooms" && ( +
+
+ setRoomSearch(e.target.value)} + className="rounded-full bg-muted" + /> +
+
+ {filteredRooms.length === 0 && ( +

+ No conversations yet. Start a chat from People tab! +

+ )} - {filteredRooms.map((room) => { - const isActive = currentRoom === room.id; - const unreadCount = messages.filter( - (m) => m.roomId === room.id && m.senderId !== mySocketId - ).length; + {filteredRooms.map((room) => { + const isActive = currentRoom === room.id; - return ( -
handleSelectRoom(room)} - className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition - ${ - isActive - ? "bg-primary text-primary-foreground" - : "hover:bg-muted" - } - `} - > -
- {room.isGroup ? ( - - ) : ( - - )} -
+ return ( +
+
handleSelectRoom(room)} + className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition flex-1 min-w-0 + ${isActive + ? "bg-primary text-primary-foreground" + : "hover:bg-muted" + }`} + > +
+ {room.isGroup ? ( + + ) : ( + + )} +
-
-

{room.name}

-

- #{room.id} +

+

{room.name}

+

+ {room.isGroup ? `${room.participants?.length ?? 0} members` : "Direct Message"} +

+
+
+ + {/* Manage button — group rooms only */} + {room.isGroup && ( + + )} +
+ ); + })} +
+ + {/* ── Manage participants panel ── */} + {managingRoom && ( +
+
+

+ {managingRoom.name}

+
+ + {/* Current members */} +
+

+ Members ({managingRoom.participants?.length ?? 0}) +

+ {(managingRoom.participants ?? []).map((p: any) => { + const pid = p._id ?? p; + const pname = p.name ?? pid; + const isSelf = pid === user?.userId; + return ( +
+
+
+ +
+ + {pname} + {isSelf && ( + (you) + )} + +
+ {!isSelf && ( + + )} +
+ ); + })} +
+ + {/* Add people */} + {(() => { + const addable = allUsers.filter( + (u) => + !(managingRoom.participants ?? []).some( + (p: any) => (p._id ?? p) === u._id + ) + ); + if (addable.length === 0) return null; + return ( +
+

+ Add people +

+ {addable.map((u) => ( +
+
+
+ +
+ {u.name} +
+ +
+ ))} +
+ ); + })()}
- ); - })} -
+ )} +
+ )} + + {/* USERS / PEOPLE LIST (for starting P2P) */} + {activeTab === "users" && ( +
+
+ setUserSearch(e.target.value)} + className="rounded-full bg-muted" + /> +
+
+ {allUsers.length === 0 ? ( +

+ No other users found. +

+ ) : ( + allUsers + .filter((u) => + !debouncedUserSearch.trim() || + u.name?.toLowerCase().includes(debouncedUserSearch.toLowerCase()) + ) + .map((u) => ( +
handleStartP2P(u)} + className="flex items-center gap-3 p-3 rounded-xl cursor-pointer transition hover:bg-muted" + > +
+ +
+
+

{u.name}

+

Click to chat

+
+
+ )) + )} +
+
+ )}
{/* ================= RIGHT CHAT ================= */} @@ -347,13 +609,20 @@ export default function ChatPage() {
- + {selectedRoom?.isGroup ? ( + + ) : ( + + )}
{selectedRoom?.name || "Select a room"} {selectedRoom && (

- #{selectedRoom.id} · {isConnected ? "Connected" : "Disconnected"} + {selectedRoom.isGroup + ? `${selectedRoom.participants?.length ?? 0} members` + : "Direct Message"}{" "} + · {isConnected ? "Connected" : "Disconnected"}

)}
@@ -392,28 +661,22 @@ export default function ChatPage() {
)} - {/* Interleave messages and notices by order */} {roomMessages.map((msg, idx) => { const isMe = msg.senderId === mySocketId; - return (
- {/* Show sender name for others */} {!isMe && ( {msg.username} )}
{msg.text}
@@ -422,7 +685,6 @@ export default function ChatPage() { ); })} - {/* Room notices */} {roomNotices.map((notice, idx) => (
@@ -441,9 +703,7 @@ export default function ChatPage() { onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} placeholder={ - currentRoom - ? `Message #${currentRoom}...` - : "Select a room first..." + currentRoom ? `Message ${selectedRoom?.name ?? ""}...` : "Select a room first..." } disabled={!currentRoom || !isConnected} /> diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 3b2146e..def0aa2 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -10,14 +10,22 @@ import { } from "react"; import { useRouter } from "next/navigation"; import { loginWithName } from "@/lib/api"; +import { jwtDecode } from "jwt-decode"; -// Represents logged-in user data stored in app +// Represents logged-in user data interface AuthUser { token: string; username: string; + userId: string; } -// Values available globally from AuthContext +// JWT structure +interface DecodedToken { + sub: string; + username: string; +} + +// Context values interface AuthContextValue { user: AuthUser | null; isLoading: boolean; @@ -25,26 +33,19 @@ interface AuthContextValue { logout: () => void; } -// Context - -// Create authentication context +// Create context const AuthContext = createContext(undefined); -// Key used to store auth data in browser storage +// Storage key const STORAGE_KEY = "chat_app_auth"; // Provider - export function AuthProvider({ children }: { children: ReactNode }) { - // Stores current logged-in user const [user, setUser] = useState(null); - - // Tracks whether auth state is still being loaded const [isLoading, setIsLoading] = useState(true); - const router = useRouter(); - // Runs once when app loads → restores user from localStorage + // Restore user from localStorage useEffect(() => { try { const stored = localStorage.getItem(STORAGE_KEY); @@ -52,13 +53,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (stored) { const parsed: AuthUser = JSON.parse(stored); - // Only set user if data is valid - if (parsed.token && parsed.username) { + if (parsed.token && parsed.username && parsed.userId) { setUser(parsed); } } } catch { - // If corrupted data exists, remove it localStorage.removeItem(STORAGE_KEY); } finally { setIsLoading(false); @@ -66,32 +65,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); // LOGIN + const login = useCallback(async (name: string) => { + const data = await loginWithName(name); - // Logs user in using API and saves data locally - const login = useCallback( - async (name: string) => { - const data = await loginWithName(name); + // ✅ Decode token here + const decoded: DecodedToken = jwtDecode(data.token); - const authUser: AuthUser = { - token: data.token, - username: data.username, - }; + const authUser: AuthUser = { + token: data.token, + username: decoded.username, + userId: decoded.sub, // ✅ extracted from token + }; - // Save user in localStorage for persistence - localStorage.setItem(STORAGE_KEY, JSON.stringify(authUser)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(authUser)); + setUser(authUser); - // Update app state - setUser(authUser); - - // Redirect to chat page after login - router.push("/chat"); - }, - [router], - ); + router.push("/chat"); + }, [router]); // LOGOUT - - // Clears user session and redirects to login page const logout = useCallback(() => { localStorage.removeItem(STORAGE_KEY); setUser(null); @@ -105,7 +97,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); } -// Custom hook to access auth state anywhere in app +// Hook export function useAuth() { const ctx = useContext(AuthContext); @@ -114,4 +106,4 @@ export function useAuth() { } return ctx; -} +} \ No newline at end of file diff --git a/src/context/SocketContext.tsx b/src/context/SocketContext.tsx index a29c21b..fa64a98 100644 --- a/src/context/SocketContext.tsx +++ b/src/context/SocketContext.tsx @@ -29,6 +29,13 @@ export interface RoomNotice { message: string; } +// Data received when a P2P room is ready +export interface P2PReadyData { + roomId: string; + targetUserId: string; + message: string; +} + // Everything that the socket context will provide to the app interface SocketContextValue { socket: Socket | null; @@ -39,6 +46,7 @@ interface SocketContextValue { joinRoom: (roomId: string) => void; leaveRoom: (roomId: string) => void; sendMessage: (roomId: string, text: string) => void; + startP2P: (targetUserId: string) => void; // chat state stored globally messages: ChatMessage[]; @@ -46,6 +54,10 @@ interface SocketContextValue { currentRoom: string | null; setCurrentRoom: (roomId: string | null) => void; clearMessages: () => void; + + // P2P readiness (chat page reacts to this) + p2pReady: P2PReadyData | null; + clearP2PReady: () => void; } //context @@ -72,9 +84,11 @@ export function SocketProvider({ children }: { children: ReactNode }) { // System notices (join/leave/info messages) const [notices, setNotices] = useState([]); - // Currently active chat room const [currentRoom, setCurrentRoom] = useState(null); + // P2P ready event data — consumed by chat page + const [p2pReady, setP2pReady] = useState(null); + // Connect to socket when user logs in, disconnect when logged out useEffect(() => { if (!user?.token) { @@ -150,6 +164,13 @@ export function SocketProvider({ children }: { children: ReactNode }) { setNotices((prev) => [...prev, data]); }); + // P2P room ready — backend already did server-side join + socket.on("p2pReady", (data: P2PReadyData) => { + console.log("[Socket] P2P Ready:", data.message); + setP2pReady(data); + setCurrentRoom(data.roomId); + }); + // New chat message received socket.on("roomMessage", (data: ChatMessage) => { setMessages((prev) => [...prev, data]); @@ -186,12 +207,23 @@ export function SocketProvider({ children }: { children: ReactNode }) { socketRef.current?.emit("roomMessage", { roomId, text }); }, []); - // Clear all messages and notices (reset chat state) + // Start a P2P conversation via socket + const startP2P = useCallback((targetUserId: string) => { + console.log(targetUserId); + socketRef.current?.emit("startP2P", { targetUserId }); + }, []); + + // Clear all messages and notices const clearMessages = useCallback(() => { setMessages([]); setNotices([]); }, []); + // Clear p2pReady after chat page has consumed it + const clearP2PReady = useCallback(() => { + setP2pReady(null); + }, []); + return ( {children} diff --git a/src/lib/api.ts b/src/lib/api.ts index 96e4d34..e111445 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,55 +1,85 @@ -// Base URL for the NestJS backend API export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; -/** - * POST /auth/token - * Authenticates (or creates) a user by name and returns a JWT token. - */ -export async function loginWithName(name: string): Promise<{ token: string; username: string }> { +export enum ConversationType { + P2P = 'p2p', + GROUP = 'group', +} + +export async function loginWithName(name: string): Promise<{ token: string; username: string; userId: string }> { const res = await fetch(`${API_BASE_URL}/auth/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); - if (!res.ok) { const error = await res.json().catch(() => ({})); throw new Error(error.message || "Login failed"); } - - return res.json(); + return res.json(); // here our backend must and will return userId too } -/** - * GET /users - * Fetches all registered users. - */ export async function fetchAllUsers(): Promise { const res = await fetch(`${API_BASE_URL}/users`); if (!res.ok) throw new Error("Failed to fetch users"); return res.json(); } -/** - * GET /conversations - * Fetches all conversations. - */ -export async function fetchConversations(): Promise { - const res = await fetch(`${API_BASE_URL}/conversations`); +// takes userId as path param +export async function fetchConversations(userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/conversations/${userId}`); if (!res.ok) throw new Error("Failed to fetch conversations"); return res.json(); } -/** - * POST /conversations - * Creates a new conversation. - */ -export async function createConversation(data: any): Promise { - const res = await fetch(`${API_BASE_URL}/conversations`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - if (!res.ok) throw new Error("Failed to create conversation"); - return res.json(); +// Participants must be user IDs now +export async function createConversation(data: { + name: string; + type: ConversationType; + participants: string[]; // user IDs, not names +}): Promise { + const res = await fetch(`${API_BASE_URL}/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.message || "Failed to create conversation"); + } + return res.json(); } + + +export async function addParticipant(convId: string, userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/conversations/${convId}/add-participant`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.message || "Failed to add participant"); + } + return res.json(); +} + + +export async function removeParticipant(convId: string, userId: string): Promise { + const res = await fetch(`${API_BASE_URL}/conversations/${convId}/remove-participant`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody.message || "Failed to remove participant"); + } + return res.json(); +} + +export async function fetchMessages(roomId: string): Promise { + if (!roomId) return []; + const res = await fetch(`${API_BASE_URL}/messages?roomId=${roomId}`); + if (!res.ok) return []; + return res.json(); +} \ No newline at end of file