added the p2p and group with the fetch data from jwt token

This commit is contained in:
sumona-banerjeee 2026-04-17 12:36:59 +05:30
parent 6fa6415bfa
commit 04128f0205
6 changed files with 526 additions and 198 deletions

10
package-lock.json generated
View File

@ -12,6 +12,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"jwt-decode": "^4.0.0",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.4", "next": "16.2.4",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@ -8053,6 +8054,15 @@
"node": ">=4.0" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@ -13,6 +13,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"jwt-decode": "^4.0.0",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.4", "next": "16.2.4",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",

View File

@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useSocket, type ChatMessage, type RoomNotice } from "@/context/SocketContext"; 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 { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -18,6 +18,9 @@ import {
Wifi, Wifi,
WifiOff, WifiOff,
Users, Users,
User as UserIcon,
UserMinus,
UserPlus,
} from "lucide-react"; } from "lucide-react";
import { import {
Avatar, Avatar,
@ -26,22 +29,17 @@ import {
} from "@/components/ui/avatar"; } from "@/components/ui/avatar";
import { useDebounce } from "@/hooks/useDebounce"; import { useDebounce } from "@/hooks/useDebounce";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface Room { interface Room {
id: string; id: string;
name: string; name: string;
isGroup: boolean; isGroup: boolean;
participants: { _id: string; name: string }[];
} }
/* ------------------------------------------------------------------ */
/* Backend rooms loaded dynamically */
/* ------------------------------------------------------------------ */
/* ------------------------------------------------------------------ */
/* ChatPage */
/* ------------------------------------------------------------------ */
export default function ChatPage() { export default function ChatPage() {
const router = useRouter(); const router = useRouter();
const { user, isLoading: authLoading, logout } = useAuth(); const { user, isLoading: authLoading, logout } = useAuth();
@ -51,71 +49,143 @@ export default function ChatPage() {
joinRoom, joinRoom,
leaveRoom, leaveRoom,
sendMessage, sendMessage,
startP2P,
messages, messages,
notices, notices,
currentRoom, currentRoom,
setCurrentRoom, setCurrentRoom,
clearMessages, clearMessages,
p2pReady,
clearP2PReady,
} = useSocket(); } = useSocket();
const [text, setText] = useState(""); const [text, setText] = useState("");
const [search, setSearch] = useState(""); const [roomSearch, setRoomSearch] = useState("");
const [activeTab, setActiveTab] = useState<"rooms" | "create">("rooms"); const [userSearch, setUserSearch] = useState("");
const [activeTab, setActiveTab] = useState<"rooms" | "users" | "create">("rooms");
const [newRoomName, setNewRoomName] = useState(""); const [newRoomName, setNewRoomName] = useState("");
const [rooms, setRooms] = useState<Room[]>([]); const [rooms, setRooms] = useState<Room[]>([]);
const [allUsers, setAllUsers] = useState<any[]>([]);
const debouncedSearch = useDebounce(search, 300); const debouncedRoomSearch = useDebounce(roomSearch, 300);
const debouncedUserSearch = useDebounce(userSearch, 300);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [managingRoom, setManagingRoom] = useState<Room | null>(null);
// ── Auth guard ──────────────────────────────────────────────────── // Auth guard
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
router.push("/login"); router.push("/login");
} }
}, [authLoading, user, router]); }, [authLoading, user, router]);
// ── Fetch rooms & Auto-join ───────────────────────────────────── // Fetch rooms & Auto-join
useEffect(() => { useEffect(() => {
if (!isConnected) return; // Guard: wait for everything needed
if (!isConnected || !user?.userId || allUsers.length === 0) return;
// Fetch conversations from API fetchConversations(user.userId)
fetchConversations()
.then((data) => { .then((data) => {
// Map backend returned data to Room format const loadedRooms = Array.isArray(data)
const loadedRooms = Array.isArray(data) ? data.map(c => ({ ? data.map((c) => {
id: c._id || c.id || "unknown", const isGroup = c.type === "group";
name: c.name || "Unnamed Group",
isGroup: c.type === "group" || c.isGroup || true 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); setRooms(loadedRooms);
// Auto-join first room // Auto-join first room (only once)
if (!currentRoom && loadedRooms.length > 0) { if (!currentRoom && loadedRooms.length > 0) {
const firstRoom = loadedRooms[0]; const firstRoom = loadedRooms[0];
setCurrentRoom(firstRoom.id); setCurrentRoom(firstRoom.id);
joinRoom(firstRoom.id); joinRoom(firstRoom.id);
} }
}) })
.catch(err => { .catch((err) => {
console.error("Failed to fetch conversations", 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(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, notices]); }, [messages, notices]);
// ── Filtered rooms ──────────────────────────────────────────────── // ── Filtered rooms
const filteredRooms = useMemo(() => { const filteredRooms = useMemo(() => {
if (!debouncedSearch.trim()) return rooms; if (!debouncedRoomSearch.trim()) return rooms;
return rooms.filter((r) => 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( const roomMessages = useMemo(
() => messages.filter((m) => m.roomId === currentRoom), () => messages.filter((m) => m.roomId === currentRoom),
[messages, currentRoom] [messages, currentRoom]
@ -126,7 +196,7 @@ export default function ChatPage() {
[notices, currentRoom] [notices, currentRoom]
); );
// ── Handlers ────────────────────────────────────────────────────── // ── Handlers
const handleSelectRoom = (room: Room) => { const handleSelectRoom = (room: Room) => {
if (currentRoom === room.id) return; if (currentRoom === room.id) return;
@ -155,20 +225,20 @@ export default function ChatPage() {
const handleCreateRoom = async () => { const handleCreateRoom = async () => {
const name = newRoomName.trim(); const name = newRoomName.trim();
if (!name) return; if (!name) return;
console.log(user);
try { try {
// Actually create the room on the backend const created = await createConversation({
const created = await createConversation({ name, type: "group", participants: [user?.username || "unknown"] }); name,
const newRoom: Room = { type: ConversationType.GROUP,
id: created._id || created.id || name.toLowerCase().replace(/\s+/g, "-"), participants: [user!.userId], // send userId not username
name: created.name || name,
isGroup: true
};
setRooms((prev) => {
if (prev.find((r) => r.id === newRoom.id)) return prev;
return [...prev, newRoom];
}); });
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(""); setNewRoomName("");
setActiveTab("rooms"); setActiveTab("rooms");
handleSelectRoom(newRoom); 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 = () => { const handleLogout = () => {
if (currentRoom) leaveRoom(currentRoom); if (currentRoom) leaveRoom(currentRoom);
clearMessages(); clearMessages();
@ -237,26 +343,33 @@ export default function ChatPage() {
</div> </div>
{/* TABS */} {/* TABS */}
<div className="flex gap-6 px-4 text-sm font-medium border-b pb-2"> <div className="flex gap-4 px-4 text-sm font-medium border-b pb-2">
<button <button
onClick={() => setActiveTab("rooms")} onClick={() => setActiveTab("rooms")}
className={`pb-1 ${ className={`pb-1 ${activeTab === "rooms"
activeTab === "rooms"
? "text-primary border-b-2 border-primary" ? "text-primary border-b-2 border-primary"
: "text-muted-foreground" : "text-muted-foreground"
}`} }`}
> >
Rooms Chats
</button>
<button
onClick={() => setActiveTab("users")}
className={`pb-1 ${activeTab === "users"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground"
}`}
>
People
</button> </button>
<button <button
onClick={() => setActiveTab("create")} onClick={() => setActiveTab("create")}
className={`pb-1 ${ className={`pb-1 ${activeTab === "create"
activeTab === "create"
? "text-primary border-b-2 border-primary" ? "text-primary border-b-2 border-primary"
: "text-muted-foreground" : "text-muted-foreground"
}`} }`}
> >
Create Group
</button> </button>
</div> </div>
@ -281,64 +394,213 @@ export default function ChatPage() {
</div> </div>
)} )}
{/* SEARCH */} {/* ROOM LIST */}
{activeTab === "rooms" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="p-4"> <div className="p-4">
<Input <Input
placeholder="Search rooms..." placeholder="Search rooms..."
value={search} value={roomSearch}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setRoomSearch(e.target.value)}
className="rounded-full bg-muted" className="rounded-full bg-muted"
/> />
</div> </div>
{/* ROOM LIST */}
<div className="flex-1 overflow-y-auto px-2 space-y-1"> <div className="flex-1 overflow-y-auto px-2 space-y-1">
{filteredRooms.length === 0 && ( {filteredRooms.length === 0 && (
<p className="p-4 text-sm text-muted-foreground"> <p className="p-4 text-sm text-muted-foreground">
No rooms found No conversations yet. Start a chat from People tab!
</p> </p>
)} )}
{filteredRooms.map((room) => { {filteredRooms.map((room) => {
const isActive = currentRoom === room.id; const isActive = currentRoom === room.id;
const unreadCount = messages.filter(
(m) => m.roomId === room.id && m.senderId !== mySocketId
).length;
return ( return (
<div key={room.id} className="flex items-center gap-1 pr-2">
<div <div
key={room.id}
onClick={() => handleSelectRoom(room)} onClick={() => handleSelectRoom(room)}
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition flex-1 min-w-0
${ ${isActive
isActive
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "hover:bg-muted" : "hover:bg-muted"
} }`}
`}
> >
<div <div
className={`w-10 h-10 flex items-center justify-center rounded-full ${ className={`w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-full ${isActive ? "bg-white/20" : "bg-primary/10"
isActive ? "bg-white/20" : "bg-primary/10"
}`} }`}
> >
{room.isGroup ? ( {room.isGroup ? (
<Users className={`w-5 h-5 ${isActive ? "text-white" : "text-primary"}`} /> <Users className={`w-5 h-5 ${isActive ? "text-white" : "text-primary"}`} />
) : ( ) : (
<Hash className={`w-5 h-5 ${isActive ? "text-white" : "text-primary"}`} /> <UserIcon className={`w-5 h-5 ${isActive ? "text-white" : "text-primary"}`} />
)} )}
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<h3 className="font-semibold truncate">{room.name}</h3> <h3 className="font-semibold truncate">{room.name}</h3>
<p className="text-xs opacity-70 truncate"> <p className="text-xs opacity-70 truncate">
#{room.id} {room.isGroup ? `${room.participants?.length ?? 0} members` : "Direct Message"}
</p> </p>
</div> </div>
</div> </div>
{/* Manage button — group rooms only */}
{room.isGroup && (
<button
onClick={() =>
setManagingRoom(managingRoom?.id === room.id ? null : room)
}
className={`flex-shrink-0 p-2 rounded-lg transition ${managingRoom?.id === room.id
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted"
}`}
title="Manage participants"
>
<Users className="w-4 h-4" />
</button>
)}
</div>
); );
})} })}
</div> </div>
{/* ── Manage participants panel ── */}
{managingRoom && (
<div className="border-t mx-2 mb-2 rounded-xl bg-muted/40 p-3 space-y-3 max-h-72 overflow-y-auto">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold truncate">
{managingRoom.name}
</p>
<button
onClick={() => setManagingRoom(null)}
className="text-xs text-muted-foreground hover:text-foreground"
>
</button>
</div>
{/* Current members */}
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Members ({managingRoom.participants?.length ?? 0})
</p>
{(managingRoom.participants ?? []).map((p: any) => {
const pid = p._id ?? p;
const pname = p.name ?? pid;
const isSelf = pid === user?.userId;
return (
<div
key={pid}
className="flex items-center justify-between gap-2 py-1"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center rounded-full bg-primary/10">
<UserIcon className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm truncate">
{pname}
{isSelf && (
<span className="text-muted-foreground text-xs ml-1">(you)</span>
)}
</span>
</div>
{!isSelf && (
<button
onClick={() => handleRemoveParticipant(managingRoom.id, pid)}
className="flex-shrink-0 p-1 rounded-md text-destructive hover:bg-destructive/10 transition"
title={`Remove ${pname}`}
>
<UserMinus className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})}
</div>
{/* Add people */}
{(() => {
const addable = allUsers.filter(
(u) =>
!(managingRoom.participants ?? []).some(
(p: any) => (p._id ?? p) === u._id
)
);
if (addable.length === 0) return null;
return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Add people
</p>
{addable.map((u) => (
<div
key={u._id}
className="flex items-center justify-between gap-2 py-1"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center rounded-full bg-primary/10">
<UserIcon className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm truncate">{u.name}</span>
</div>
<button
onClick={() => handleAddParticipant(managingRoom.id, u._id)}
className="flex-shrink-0 p-1 rounded-md text-primary hover:bg-primary/10 transition"
title={`Add ${u.name}`}
>
<UserPlus className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
);
})()}
</div>
)}
</div>
)}
{/* USERS / PEOPLE LIST (for starting P2P) */}
{activeTab === "users" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="p-4">
<Input
placeholder="Search people..."
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="rounded-full bg-muted"
/>
</div>
<div className="flex-1 overflow-y-auto px-2 space-y-1">
{allUsers.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
No other users found.
</p>
) : (
allUsers
.filter((u) =>
!debouncedUserSearch.trim() ||
u.name?.toLowerCase().includes(debouncedUserSearch.toLowerCase())
)
.map((u) => (
<div
key={u._id}
onClick={() => handleStartP2P(u)}
className="flex items-center gap-3 p-3 rounded-xl cursor-pointer transition hover:bg-muted"
>
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-primary/10">
<UserIcon className="w-5 h-5 text-primary" />
</div>
<div className="flex-1 overflow-hidden">
<h3 className="font-semibold truncate">{u.name}</h3>
<p className="text-xs opacity-70 truncate">Click to chat</p>
</div>
</div>
))
)}
</div>
</div>
)}
</div> </div>
{/* ================= RIGHT CHAT ================= */} {/* ================= RIGHT CHAT ================= */}
@ -347,13 +609,20 @@ export default function ChatPage() {
<div className="p-4 border-b font-semibold flex items-center justify-between"> <div className="p-4 border-b font-semibold flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-primary/10"> <div className="w-10 h-10 flex items-center justify-center rounded-full bg-primary/10">
{selectedRoom?.isGroup ? (
<Users className="w-5 h-5 text-primary" /> <Users className="w-5 h-5 text-primary" />
) : (
<UserIcon className="w-5 h-5 text-primary" />
)}
</div> </div>
<div> <div>
<span className="text-lg">{selectedRoom?.name || "Select a room"}</span> <span className="text-lg">{selectedRoom?.name || "Select a room"}</span>
{selectedRoom && ( {selectedRoom && (
<p className="text-xs text-muted-foreground font-normal"> <p className="text-xs text-muted-foreground font-normal">
#{selectedRoom.id} · {isConnected ? "Connected" : "Disconnected"} {selectedRoom.isGroup
? `${selectedRoom.participants?.length ?? 0} members`
: "Direct Message"}{" "}
· {isConnected ? "Connected" : "Disconnected"}
</p> </p>
)} )}
</div> </div>
@ -392,27 +661,21 @@ export default function ChatPage() {
</div> </div>
)} )}
{/* Interleave messages and notices by order */}
{roomMessages.map((msg, idx) => { {roomMessages.map((msg, idx) => {
const isMe = msg.senderId === mySocketId; const isMe = msg.senderId === mySocketId;
return ( return (
<div <div
key={`msg-${idx}`} key={`msg-${idx}`}
className={`flex ${isMe ? "justify-end" : "justify-start"}`} className={`flex ${isMe ? "justify-end" : "justify-start"}`}
> >
<div className="flex flex-col max-w-xs"> <div className="flex flex-col max-w-xs">
{/* Show sender name for others */}
{!isMe && ( {!isMe && (
<span className="text-xs text-muted-foreground mb-1 ml-1"> <span className="text-xs text-muted-foreground mb-1 ml-1">
{msg.username} {msg.username}
</span> </span>
)} )}
<div <div
className={`px-4 py-2 rounded-xl ${ className={`px-4 py-2 rounded-xl ${isMe ? "bg-primary text-primary-foreground" : "bg-muted"
isMe
? "bg-primary text-primary-foreground"
: "bg-muted"
}`} }`}
> >
{msg.text} {msg.text}
@ -422,7 +685,6 @@ export default function ChatPage() {
); );
})} })}
{/* Room notices */}
{roomNotices.map((notice, idx) => ( {roomNotices.map((notice, idx) => (
<div key={`notice-${idx}`} className="flex justify-center"> <div key={`notice-${idx}`} className="flex justify-center">
<span className="text-xs text-muted-foreground bg-muted/50 px-3 py-1 rounded-full"> <span className="text-xs text-muted-foreground bg-muted/50 px-3 py-1 rounded-full">
@ -441,9 +703,7 @@ export default function ChatPage() {
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
currentRoom currentRoom ? `Message ${selectedRoom?.name ?? ""}...` : "Select a room first..."
? `Message #${currentRoom}...`
: "Select a room first..."
} }
disabled={!currentRoom || !isConnected} disabled={!currentRoom || !isConnected}
/> />

View File

@ -10,14 +10,22 @@ import {
} from "react"; } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { loginWithName } from "@/lib/api"; 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 { interface AuthUser {
token: string; token: string;
username: string; username: string;
userId: string;
} }
// Values available globally from AuthContext // JWT structure
interface DecodedToken {
sub: string;
username: string;
}
// Context values
interface AuthContextValue { interface AuthContextValue {
user: AuthUser | null; user: AuthUser | null;
isLoading: boolean; isLoading: boolean;
@ -25,26 +33,19 @@ interface AuthContextValue {
logout: () => void; logout: () => void;
} }
// Context // Create context
// Create authentication context
const AuthContext = createContext<AuthContextValue | undefined>(undefined); const AuthContext = createContext<AuthContextValue | undefined>(undefined);
// Key used to store auth data in browser storage // Storage key
const STORAGE_KEY = "chat_app_auth"; const STORAGE_KEY = "chat_app_auth";
// Provider // Provider
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
// Stores current logged-in user
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
// Tracks whether auth state is still being loaded
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const router = useRouter(); const router = useRouter();
// Runs once when app loads → restores user from localStorage // Restore user from localStorage
useEffect(() => { useEffect(() => {
try { try {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@ -52,13 +53,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (stored) { if (stored) {
const parsed: AuthUser = JSON.parse(stored); const parsed: AuthUser = JSON.parse(stored);
// Only set user if data is valid if (parsed.token && parsed.username && parsed.userId) {
if (parsed.token && parsed.username) {
setUser(parsed); setUser(parsed);
} }
} }
} catch { } catch {
// If corrupted data exists, remove it
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -66,32 +65,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
// LOGIN // LOGIN
const login = useCallback(async (name: string) => {
// Logs user in using API and saves data locally
const login = useCallback(
async (name: string) => {
const data = await loginWithName(name); const data = await loginWithName(name);
// ✅ Decode token here
const decoded: DecodedToken = jwtDecode(data.token);
const authUser: AuthUser = { const authUser: AuthUser = {
token: data.token, token: data.token,
username: data.username, 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));
// Update app state
setUser(authUser); setUser(authUser);
// Redirect to chat page after login
router.push("/chat"); router.push("/chat");
}, }, [router]);
[router],
);
// LOGOUT // LOGOUT
// Clears user session and redirects to login page
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
setUser(null); 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() { export function useAuth() {
const ctx = useContext(AuthContext); const ctx = useContext(AuthContext);

View File

@ -29,6 +29,13 @@ export interface RoomNotice {
message: string; 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 // Everything that the socket context will provide to the app
interface SocketContextValue { interface SocketContextValue {
socket: Socket | null; socket: Socket | null;
@ -39,6 +46,7 @@ interface SocketContextValue {
joinRoom: (roomId: string) => void; joinRoom: (roomId: string) => void;
leaveRoom: (roomId: string) => void; leaveRoom: (roomId: string) => void;
sendMessage: (roomId: string, text: string) => void; sendMessage: (roomId: string, text: string) => void;
startP2P: (targetUserId: string) => void;
// chat state stored globally // chat state stored globally
messages: ChatMessage[]; messages: ChatMessage[];
@ -46,6 +54,10 @@ interface SocketContextValue {
currentRoom: string | null; currentRoom: string | null;
setCurrentRoom: (roomId: string | null) => void; setCurrentRoom: (roomId: string | null) => void;
clearMessages: () => void; clearMessages: () => void;
// P2P readiness (chat page reacts to this)
p2pReady: P2PReadyData | null;
clearP2PReady: () => void;
} }
//context //context
@ -72,9 +84,11 @@ export function SocketProvider({ children }: { children: ReactNode }) {
// System notices (join/leave/info messages) // System notices (join/leave/info messages)
const [notices, setNotices] = useState<RoomNotice[]>([]); const [notices, setNotices] = useState<RoomNotice[]>([]);
// Currently active chat room
const [currentRoom, setCurrentRoom] = useState<string | null>(null); const [currentRoom, setCurrentRoom] = useState<string | null>(null);
// P2P ready event data — consumed by chat page
const [p2pReady, setP2pReady] = useState<P2PReadyData | null>(null);
// Connect to socket when user logs in, disconnect when logged out // Connect to socket when user logs in, disconnect when logged out
useEffect(() => { useEffect(() => {
if (!user?.token) { if (!user?.token) {
@ -150,6 +164,13 @@ export function SocketProvider({ children }: { children: ReactNode }) {
setNotices((prev) => [...prev, data]); 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 // New chat message received
socket.on("roomMessage", (data: ChatMessage) => { socket.on("roomMessage", (data: ChatMessage) => {
setMessages((prev) => [...prev, data]); setMessages((prev) => [...prev, data]);
@ -186,12 +207,23 @@ export function SocketProvider({ children }: { children: ReactNode }) {
socketRef.current?.emit("roomMessage", { roomId, text }); 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(() => { const clearMessages = useCallback(() => {
setMessages([]); setMessages([]);
setNotices([]); setNotices([]);
}, []); }, []);
// Clear p2pReady after chat page has consumed it
const clearP2PReady = useCallback(() => {
setP2pReady(null);
}, []);
return ( return (
<SocketContext.Provider <SocketContext.Provider
value={{ value={{
@ -201,11 +233,14 @@ export function SocketProvider({ children }: { children: ReactNode }) {
joinRoom, joinRoom,
leaveRoom, leaveRoom,
sendMessage, sendMessage,
startP2P,
messages, messages,
notices, notices,
currentRoom, currentRoom,
setCurrentRoom, setCurrentRoom,
clearMessages, clearMessages,
p2pReady,
clearP2PReady,
}} }}
> >
{children} {children}

View File

@ -1,55 +1,85 @@
// Base URL for the NestJS backend API
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
/** export enum ConversationType {
* POST /auth/token P2P = 'p2p',
* Authenticates (or creates) a user by name and returns a JWT token. GROUP = 'group',
*/ }
export async function loginWithName(name: string): Promise<{ token: string; username: string }> {
export async function loginWithName(name: string): Promise<{ token: string; username: string; userId: string }> {
const res = await fetch(`${API_BASE_URL}/auth/token`, { const res = await fetch(`${API_BASE_URL}/auth/token`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => ({})); const error = await res.json().catch(() => ({}));
throw new Error(error.message || "Login failed"); throw new Error(error.message || "Login failed");
} }
return res.json(); // here our backend must and will return userId too
return res.json();
} }
/**
* GET /users
* Fetches all registered users.
*/
export async function fetchAllUsers(): Promise<any[]> { export async function fetchAllUsers(): Promise<any[]> {
const res = await fetch(`${API_BASE_URL}/users`); const res = await fetch(`${API_BASE_URL}/users`);
if (!res.ok) throw new Error("Failed to fetch users"); if (!res.ok) throw new Error("Failed to fetch users");
return res.json(); return res.json();
} }
/** // takes userId as path param
* GET /conversations export async function fetchConversations(userId: string): Promise<any[]> {
* Fetches all conversations. const res = await fetch(`${API_BASE_URL}/conversations/${userId}`);
*/
export async function fetchConversations(): Promise<any[]> {
const res = await fetch(`${API_BASE_URL}/conversations`);
if (!res.ok) throw new Error("Failed to fetch conversations"); if (!res.ok) throw new Error("Failed to fetch conversations");
return res.json(); return res.json();
} }
/** // Participants must be user IDs now
* POST /conversations export async function createConversation(data: {
* Creates a new conversation. name: string;
*/ type: ConversationType;
export async function createConversation(data: any): Promise<any> { participants: string[]; // user IDs, not names
}): Promise<any> {
const res = await fetch(`${API_BASE_URL}/conversations`, { const res = await fetch(`${API_BASE_URL}/conversations`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!res.ok) throw new Error("Failed to create conversation"); 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<any> {
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<any> {
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<any[]> {
if (!roomId) return [];
const res = await fetch(`${API_BASE_URL}/messages?roomId=${roomId}`);
if (!res.ok) return [];
return res.json(); return res.json();
} }