added the date time and proper conversation history

This commit is contained in:
sumona-banerjeee 2026-04-17 15:29:48 +05:30
parent 04128f0205
commit d2ea0ff9cb
3 changed files with 432 additions and 334 deletions

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, fetchAllUsers, ConversationType, addParticipant, removeParticipant } from "@/lib/api"; import { fetchConversations, createConversation, fetchAllUsers, ConversationType, addParticipant, removeParticipant, fetchMessages } 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 {
@ -14,23 +14,12 @@ import {
Plus, Plus,
LogOut, LogOut,
Loader2, Loader2,
Hash,
Wifi,
WifiOff,
Users, Users,
User as UserIcon, User as UserIcon,
UserMinus, UserMinus,
UserPlus, UserPlus,
ChevronDown,
} from "lucide-react"; } from "lucide-react";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { useDebounce } from "@/hooks/useDebounce";
interface Room { interface Room {
id: string; id: string;
@ -39,7 +28,6 @@ interface Room {
participants: { _id: string; name: string }[]; participants: { _id: string; name: string }[];
} }
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();
@ -56,21 +44,36 @@ export default function ChatPage() {
setCurrentRoom, setCurrentRoom,
clearMessages, clearMessages,
p2pReady, p2pReady,
setMessages,
clearP2PReady, clearP2PReady,
} = useSocket(); } = useSocket();
const [text, setText] = useState(""); const [text, setText] = useState("");
const [roomSearch, setRoomSearch] = useState(""); const [roomSearch, setRoomSearch] = useState("");
const [userSearch, setUserSearch] = useState(""); const [userSearch, setUserSearch] = useState("");
const [activeTab, setActiveTab] = useState<"rooms" | "users" | "create">("rooms"); const [activeTab, setActiveTab] = useState<"rooms" | "users">("rooms");
const [showCreateDropdown, setShowCreateDropdown] = useState(false);
const [newRoomName, setNewRoomName] = useState(""); const [newRoomName, setNewRoomName] = useState("");
const [rooms, setRooms] = useState<Room[]>([]); const [rooms, setRooms] = useState<Room[]>([]);
const [allUsers, setAllUsers] = useState<any[]>([]); const [allUsers, setAllUsers] = useState<any[]>([]);
const [showManageDropdown, setShowManageDropdown] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const manageDropdownRef = useRef<HTMLDivElement>(null);
// Debounce helper
const useDebounce = (value: string, delay: number) => {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
};
const debouncedRoomSearch = useDebounce(roomSearch, 300); const debouncedRoomSearch = useDebounce(roomSearch, 300);
const debouncedUserSearch = useDebounce(userSearch, 300); const debouncedUserSearch = useDebounce(userSearch, 300);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [managingRoom, setManagingRoom] = useState<Room | null>(null);
// Auth guard // Auth guard
useEffect(() => { useEffect(() => {
@ -81,7 +84,6 @@ export default function ChatPage() {
// Fetch rooms & Auto-join // Fetch rooms & Auto-join
useEffect(() => { useEffect(() => {
// Guard: wait for everything needed
if (!isConnected || !user?.userId || allUsers.length === 0) return; if (!isConnected || !user?.userId || allUsers.length === 0) return;
fetchConversations(user.userId) fetchConversations(user.userId)
@ -89,33 +91,22 @@ export default function ChatPage() {
const loadedRooms = Array.isArray(data) const loadedRooms = Array.isArray(data)
? data.map((c) => { ? data.map((c) => {
const isGroup = c.type === "group"; const isGroup = c.type === "group";
let roomName = c.name; let roomName = c.name;
// FIX: derive name for P2P
if (!isGroup) { if (!isGroup) {
let otherUserName = "Unknown User"; let otherUserName = "Unknown User";
// Case 1: participants are objects
if (typeof c.participants?.[0] === "object") { if (typeof c.participants?.[0] === "object") {
const otherUser = c.participants.find( const otherUser = c.participants.find(
(p: any) => p._id !== user.userId (p: any) => p._id !== user.userId
); );
otherUserName = otherUser?.name || otherUserName; otherUserName = otherUser?.name || otherUserName;
} } else {
// Case 2: participants are IDs
else {
const otherUserId = c.participants?.find( const otherUserId = c.participants?.find(
(id: string) => id !== user.userId (id: string) => id !== user.userId
); );
const otherUser = allUsers.find((u) => u._id === otherUserId);
const otherUser = allUsers.find(
(u) => u._id === otherUserId
);
otherUserName = otherUser?.name || otherUserName; otherUserName = otherUser?.name || otherUserName;
} }
roomName = otherUserName; roomName = otherUserName;
} }
@ -130,7 +121,6 @@ export default function ChatPage() {
setRooms(loadedRooms); setRooms(loadedRooms);
// 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);
@ -142,7 +132,40 @@ export default function ChatPage() {
}); });
}, [isConnected, user, allUsers, currentRoom, joinRoom, setCurrentRoom]); }, [isConnected, user, allUsers, currentRoom, joinRoom, setCurrentRoom]);
// Fetch all users for P2P tab
useEffect(() => {
if (!currentRoom) return;
fetchMessages(currentRoom)
.then((data) => {
const normalized = data.map((msg: any) => ({
_id: msg._id,
roomId: msg.conversationId,
senderId: msg.senderId._id,
username: msg.senderId.name,
text: msg.text,
createdAt: msg.createdAt,
}));
// Replace only this room's messages
setMessages((prev) => {
const others = prev.filter((m) => m.roomId !== currentRoom);
return [...others, ...normalized];
});
})
.catch((err) => {
console.error("Failed to fetch messages:", err);
});
}, [currentRoom, setMessages]);
// Fetch all users
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
fetchAllUsers() fetchAllUsers()
@ -154,30 +177,52 @@ export default function ChatPage() {
.catch(console.error); .catch(console.error);
}, [user]); }, [user]);
// ── React to P2P Ready event // React to P2P Ready event
useEffect(() => { useEffect(() => {
if (!p2pReady) return; if (!p2pReady) return;
const targetUser = allUsers.find((u) => u._id === p2pReady.targetUserId); const targetUser = allUsers.find((u) => u._id === p2pReady.targetUserId);
const roomName = targetUser?.name || "Direct Message"; const roomName = targetUser?.name || "Direct Message";
// Add the new P2P room to sidebar if not already there
setRooms((prev) => { setRooms((prev) => {
if (prev.find((r) => r.id === p2pReady.roomId)) return prev; if (prev.find((r) => r.id === p2pReady.roomId)) return prev;
return [...prev, { id: p2pReady.roomId, name: roomName, isGroup: false }]; return [
...prev,
{ id: p2pReady.roomId, name: roomName, isGroup: false, participants: [] },
];
}); });
// Switch to rooms tab
setActiveTab("rooms"); setActiveTab("rooms");
clearP2PReady(); clearP2PReady();
}, [p2pReady, allUsers, clearP2PReady]); }, [p2pReady, allUsers, clearP2PReady]);
// ── Scroll to bottom on new messages // Scroll to bottom on new messages
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, notices]); }, [messages, notices]);
// ── Filtered rooms // Close create dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!dropdownRef.current) return;
if (dropdownRef.current.contains(e.target as Node)) return;
setShowCreateDropdown(false);
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Close manage dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!manageDropdownRef.current) return;
if (manageDropdownRef.current.contains(e.target as Node)) return;
setShowManageDropdown(false);
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Filtered rooms
const filteredRooms = useMemo(() => { const filteredRooms = useMemo(() => {
if (!debouncedRoomSearch.trim()) return rooms; if (!debouncedRoomSearch.trim()) return rooms;
return rooms.filter((r) => return rooms.filter((r) =>
@ -185,7 +230,7 @@ export default function ChatPage() {
); );
}, [rooms, debouncedRoomSearch]); }, [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]
@ -196,17 +241,13 @@ 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;
if (currentRoom) leaveRoom(currentRoom);
// Leave old room
if (currentRoom) {
leaveRoom(currentRoom);
}
// Join new room
setCurrentRoom(room.id); setCurrentRoom(room.id);
joinRoom(room.id); joinRoom(room.id);
setShowManageDropdown(false);
}; };
const handleSend = () => { const handleSend = () => {
@ -225,12 +266,11 @@ 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 {
const created = await createConversation({ const created = await createConversation({
name, name,
type: ConversationType.GROUP, type: ConversationType.GROUP,
participants: [user!.userId], // send userId not username participants: [user!.userId],
}); });
const newRoom: Room = { const newRoom: Room = {
id: created._id || created.id, id: created._id || created.id,
@ -238,8 +278,11 @@ export default function ChatPage() {
isGroup: true, isGroup: true,
participants: created.participants || [], participants: created.participants || [],
}; };
setRooms(prev => prev.find(r => r.id === newRoom.id) ? prev : [...prev, newRoom]); setRooms((prev) =>
prev.find((r) => r.id === newRoom.id) ? prev : [...prev, newRoom]
);
setNewRoomName(""); setNewRoomName("");
setShowCreateDropdown(false);
setActiveTab("rooms"); setActiveTab("rooms");
handleSelectRoom(newRoom); handleSelectRoom(newRoom);
} catch (err) { } catch (err) {
@ -247,17 +290,30 @@ export default function ChatPage() {
} }
}; };
const handleAddParticipant = async (convId: string, userId: string) => { const handleAddParticipant = async (convId: string, userId: string) => {
try { try {
const updated = await addParticipant(convId, userId); const updated = await addParticipant(convId, userId);
setRooms(prev => prev.map(r =>
r.id === convId // Normalize participants: if they're plain ID strings, hydrate with names from allUsers
? { ...r, participants: updated.participants || r.participants } const rawParticipants: any[] = updated.participants || [];
: r const hydrated = rawParticipants.map((p: any) => {
)); if (typeof p === "string") {
// refresh managingRoom state const found = allUsers.find((u) => u._id === p);
setManagingRoom(prev => prev?.id === convId ? { ...prev, participants: updated.participants || prev.participants } : prev); return found ? { _id: found._id, name: found.name } : { _id: p, name: p };
}
// Already an object — but name might be missing, try to fill it in
if (!p.name) {
const found = allUsers.find((u) => u._id === (p._id ?? p));
return found ? { _id: found._id, name: found.name } : p;
}
return p;
});
setRooms((prev) =>
prev.map((r) =>
r.id === convId ? { ...r, participants: hydrated } : r
)
);
} catch (err: any) { } catch (err: any) {
console.error("Add participant failed:", err.message); console.error("Add participant failed:", err.message);
} }
@ -266,18 +322,53 @@ export default function ChatPage() {
const handleRemoveParticipant = async (convId: string, userId: string) => { const handleRemoveParticipant = async (convId: string, userId: string) => {
try { try {
const updated = await removeParticipant(convId, userId); const updated = await removeParticipant(convId, userId);
setRooms(prev => prev.map(r =>
r.id === convId const rawParticipants: any[] = updated.participants || [];
? { ...r, participants: updated.participants || r.participants } const hydrated = rawParticipants.map((p: any) => {
: r if (typeof p === "string") {
)); const found = allUsers.find((u) => u._id === p);
setManagingRoom(prev => prev?.id === convId ? { ...prev, participants: updated.participants || prev.participants } : prev); return found ? { _id: found._id, name: found.name } : { _id: p, name: p };
}
if (!p.name) {
const found = allUsers.find((u) => u._id === (p._id ?? p));
return found ? { _id: found._id, name: found.name } : p;
}
return p;
});
setRooms((prev) =>
prev.map((r) =>
r.id === convId ? { ...r, participants: hydrated } : r
)
);
} catch (err: any) { } catch (err: any) {
console.error("Remove participant failed:", err.message); console.error("Remove participant failed:", err.message);
} }
}; };
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) return "Today";
return date.toLocaleDateString([], {
day: "numeric",
month: "short",
year: "numeric",
});
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
};
const handleStartP2P = (targetUser: any) => { const handleStartP2P = (targetUser: any) => {
if (!targetUser?._id) return; if (!targetUser?._id) return;
startP2P(targetUser._id); startP2P(targetUser._id);
@ -289,7 +380,7 @@ export default function ChatPage() {
logout(); logout();
}; };
// ── Loading / Auth guard ────────────────────────────────────────── // Loading / Auth guard
if (authLoading || !user) { if (authLoading || !user) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background"> <div className="flex min-h-screen items-center justify-center bg-background">
@ -300,6 +391,12 @@ export default function ChatPage() {
const selectedRoom = rooms.find((r) => r.id === currentRoom); const selectedRoom = rooms.find((r) => r.id === currentRoom);
// Participants for manage dropdown (current selected group room)
const manageParticipants = selectedRoom?.participants ?? [];
const addableUsers = allUsers.filter(
(u) => !manageParticipants.some((p: any) => (p._id ?? p) === u._id)
);
return ( return (
<div className="flex h-screen"> <div className="flex h-screen">
{/* ================= LEFT SIDEBAR ================= */} {/* ================= LEFT SIDEBAR ================= */}
@ -308,23 +405,44 @@ export default function ChatPage() {
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">Chats</h2> <h2 className="text-xl font-semibold">Chats</h2>
{/* Connection indicator */}
{isConnected ? (
<Wifi className="w-4 h-4 text-green-500" />
) : (
<WifiOff className="w-4 h-4 text-red-500" />
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button {/* Create Group Dropdown */}
size="icon" <div className="relative" ref={dropdownRef}>
variant="ghost" <Button
className="rounded-full" size="icon"
onClick={() => setActiveTab(activeTab === "create" ? "rooms" : "create")} variant="ghost"
title="Create room" className="rounded-full"
> onClick={() => setShowCreateDropdown((prev) => !prev)}
<Plus className="w-4 h-4" /> title="Create group"
</Button> >
<Plus className="w-4 h-4" />
</Button>
{showCreateDropdown && (
<div className="absolute right-0 mt-2 w-64 bg-background border rounded-xl shadow-lg p-3 space-y-2 z-50">
<Input
placeholder="Room name..."
value={newRoomName}
onChange={(e) => setNewRoomName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateRoom();
}}
autoFocus
/>
<Button
onClick={handleCreateRoom}
disabled={!newRoomName.trim()}
className="w-full"
>
Create Group
</Button>
</div>
)}
</div>
{/* Logout */}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@ -339,10 +457,11 @@ export default function ChatPage() {
{/* USER INFO */} {/* USER INFO */}
<div className="px-4 pb-2 text-sm text-muted-foreground"> <div className="px-4 pb-2 text-sm text-muted-foreground">
Logged in as <span className="font-medium text-foreground">{user.username}</span> Logged in as{" "}
<span className="font-medium text-foreground">{user.username}</span>
</div> </div>
{/* TABS */} {/* TABS — only Chats & People */}
<div className="flex gap-4 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")}
@ -362,38 +481,8 @@ export default function ChatPage() {
> >
People People
</button> </button>
<button
onClick={() => setActiveTab("create")}
className={`pb-1 ${activeTab === "create"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground"
}`}
>
Group
</button>
</div> </div>
{/* CREATE ROOM PANEL */}
{activeTab === "create" && (
<div className="p-4 space-y-3 border-b">
<Input
placeholder="Room name..."
value={newRoomName}
onChange={(e) => setNewRoomName(e.target.value)}
className="rounded-full bg-muted"
onKeyDown={(e) => e.key === "Enter" && handleCreateRoom()}
/>
<Button
onClick={handleCreateRoom}
disabled={!newRoomName.trim()}
className="w-full rounded-full"
>
<Plus className="w-4 h-4 mr-2" />
Create Room
</Button>
</div>
)}
{/* ROOM LIST */} {/* ROOM LIST */}
{activeTab === "rooms" && ( {activeTab === "rooms" && (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
@ -414,153 +503,43 @@ export default function ChatPage() {
{filteredRooms.map((room) => { {filteredRooms.map((room) => {
const isActive = currentRoom === room.id; const isActive = currentRoom === room.id;
return ( return (
<div key={room.id} className="flex items-center gap-1 pr-2"> <div
key={room.id}
onClick={() => handleSelectRoom(room)}
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition
${isActive ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
>
<div <div
onClick={() => handleSelectRoom(room)} className={`w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-full ${isActive ? "bg-white/20" : "bg-primary/10"
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"
}`} }`}
> >
<div {room.isGroup ? (
className={`w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-full ${isActive ? "bg-white/20" : "bg-primary/10" <Users
}`} className={`w-5 h-5 ${isActive ? "text-white" : "text-primary"}`}
> />
{room.isGroup ? ( ) : (
<Users className={`w-5 h-5 ${isActive ? "text-white" : "text-primary"}`} /> <UserIcon
) : ( 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.isGroup
{room.isGroup ? `${room.participants?.length ?? 0} members` : "Direct Message"} ? `${room.participants?.length ?? 0} members`
</p> : "Direct Message"}
</div> </p>
</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> </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> </div>
)} )}
{/* USERS / PEOPLE LIST (for starting P2P) */} {/* PEOPLE LIST */}
{activeTab === "users" && ( {activeTab === "users" && (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<div className="p-4"> <div className="p-4">
@ -578,9 +557,12 @@ export default function ChatPage() {
</p> </p>
) : ( ) : (
allUsers allUsers
.filter((u) => .filter(
!debouncedUserSearch.trim() || (u) =>
u.name?.toLowerCase().includes(debouncedUserSearch.toLowerCase()) !debouncedUserSearch.trim() ||
u.name
?.toLowerCase()
.includes(debouncedUserSearch.toLowerCase())
) )
.map((u) => ( .map((u) => (
<div <div
@ -593,7 +575,9 @@ export default function ChatPage() {
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<h3 className="font-semibold truncate">{u.name}</h3> <h3 className="font-semibold truncate">{u.name}</h3>
<p className="text-xs opacity-70 truncate">Click to chat</p> <p className="text-xs opacity-70 truncate">
Click to chat
</p>
</div> </div>
</div> </div>
)) ))
@ -616,7 +600,9 @@ export default function ChatPage() {
)} )}
</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.isGroup {selectedRoom.isGroup
@ -628,7 +614,7 @@ export default function ChatPage() {
</div> </div>
</div> </div>
{/* Call Buttons */} {/* Right-side action buttons */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
@ -644,6 +630,103 @@ export default function ChatPage() {
> >
<Video className="w-5 h-5" /> <Video className="w-5 h-5" />
</Button> </Button>
{/* Manage Participants — only for group rooms */}
{selectedRoom?.isGroup && (
<div className="relative" ref={manageDropdownRef}>
<Button
variant="ghost"
size="icon"
onClick={() => setShowManageDropdown((prev) => !prev)}
title="Manage participants"
className={showManageDropdown ? "bg-muted" : ""}
>
<UserPlus className="w-5 h-5" />
</Button>
{showManageDropdown && (
<div className="absolute right-0 mt-2 w-72 bg-background border rounded-xl shadow-lg p-3 space-y-3 z-50 max-h-80 overflow-y-auto">
<p className="text-sm font-semibold truncate">
{selectedRoom.name}
</p>
{/* Current members */}
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Members ({manageParticipants.length})
</p>
{manageParticipants.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(selectedRoom.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 */}
{addableUsers.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Add people
</p>
{addableUsers.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(selectedRoom.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>
)}
</div> </div>
</div> </div>
@ -655,35 +738,65 @@ export default function ChatPage() {
</div> </div>
)} )}
{currentRoom && roomMessages.length === 0 && roomNotices.length === 0 && ( {currentRoom &&
<div className="flex items-center justify-center h-full text-muted-foreground"> roomMessages.length === 0 &&
No messages yet. Say something! roomNotices.length === 0 && (
</div> <div className="flex items-center justify-center h-full text-muted-foreground">
)} No messages yet. Say something!
</div>
)}
{roomMessages.map((msg, idx) => { {(() => {
const isMe = msg.senderId === mySocketId; let lastDate = "";
return (
<div return roomMessages.map((msg, idx) => {
key={`msg-${idx}`} const msgDate = formatDate(msg.createdAt!);
className={`flex ${isMe ? "justify-end" : "justify-start"}`} const showDate = msgDate !== lastDate;
> lastDate = msgDate;
<div className="flex flex-col max-w-xs">
{!isMe && ( const isMe =
<span className="text-xs text-muted-foreground mb-1 ml-1"> msg.senderId === user?.userId ||
{msg.username} msg.senderId === mySocketId;
</span>
return (
<div key={msg._id || idx}>
{/* DATE SEPARATOR */}
{showDate && (
<div className="flex justify-center my-4">
<span className="text-xs bg-muted px-3 py-1 rounded-full text-muted-foreground">
{msgDate}
</span>
</div>
)} )}
<div
className={`px-4 py-2 rounded-xl ${isMe ? "bg-primary text-primary-foreground" : "bg-muted" {/* MESSAGE */}
}`} <div className={`flex ${isMe ? "justify-end" : "justify-start"}`}>
> <div className="flex flex-col max-w-xs">
{msg.text} {!isMe && (
<span className="text-xs text-muted-foreground mb-1 ml-1">
{msg.username}
</span>
)}
<div
className={`px-4 py-2 rounded-xl ${isMe
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
>
<div>{msg.text}</div>
{/* TIME */}
<div className="text-[10px] text-right opacity-70 mt-1">
{formatTime(msg.createdAt!)}
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> );
); });
})} })()}
{roomNotices.map((notice, idx) => ( {roomNotices.map((notice, idx) => (
<div key={`notice-${idx}`} className="flex justify-center"> <div key={`notice-${idx}`} className="flex justify-center">
@ -703,7 +816,9 @@ export default function ChatPage() {
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
currentRoom ? `Message ${selectedRoom?.name ?? ""}...` : "Select a room first..." currentRoom
? `Message ${selectedRoom?.name ?? ""}...`
: "Select a room first..."
} }
disabled={!currentRoom || !isConnected} disabled={!currentRoom || !isConnected}
/> />

View File

@ -13,7 +13,9 @@ import { io, Socket } from "socket.io-client";
import { useAuth } from "./AuthContext"; import { useAuth } from "./AuthContext";
import { API_BASE_URL } from "@/lib/api"; import { API_BASE_URL } from "@/lib/api";
// Shape of a chat message coming from the server //types
// Chat message
export interface ChatMessage { export interface ChatMessage {
_id?: string; _id?: string;
roomId: string; roomId: string;
@ -23,76 +25,67 @@ export interface ChatMessage {
createdAt?: string; createdAt?: string;
} }
// System messages related to a room (join/leave/info) // System messages
export interface RoomNotice { export interface RoomNotice {
roomId: string; roomId: string;
message: string; message: string;
} }
// Data received when a P2P room is ready // P2P Ready
export interface P2PReadyData { export interface P2PReadyData {
roomId: string; roomId: string;
targetUserId: string; targetUserId: string;
message: string; message: string;
} }
// Everything that the socket context will provide to the app //context value
interface SocketContextValue { interface SocketContextValue {
socket: Socket | null; socket: Socket | null;
isConnected: boolean; isConnected: boolean;
mySocketId: string | null; mySocketId: string | null;
// join / leave / send chat messages
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; startP2P: (targetUserId: string) => void;
// chat state stored globally
messages: ChatMessage[]; messages: ChatMessage[];
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
notices: RoomNotice[]; notices: RoomNotice[];
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; p2pReady: P2PReadyData | null;
clearP2PReady: () => void; clearP2PReady: () => void;
} }
//context //context
// Create a global socket context
const SocketContext = createContext<SocketContextValue | undefined>(undefined); const SocketContext = createContext<SocketContextValue | undefined>(undefined);
/* Provider */ //provider
export function SocketProvider({ children }: { children: ReactNode }) { export function SocketProvider({ children }: { children: ReactNode }) {
const { user } = useAuth(); const { user } = useAuth();
// Keep socket instance in a ref so it persists without rerenders
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
// Connection status
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
// Store current socket id assigned by server
const [mySocketId, setMySocketId] = useState<string | null>(null); const [mySocketId, setMySocketId] = useState<string | null>(null);
// All chat messages received
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
// System notices (join/leave/info messages)
const [notices, setNotices] = useState<RoomNotice[]>([]); const [notices, setNotices] = useState<RoomNotice[]>([]);
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); const [p2pReady, setP2pReady] = useState<P2PReadyData | null>(null);
// Connect to socket when user logs in, disconnect when logged out //socket connection
useEffect(() => { useEffect(() => {
if (!user?.token) { if (!user?.token) {
// If user logs out, close socket connection
if (socketRef.current) { if (socketRef.current) {
socketRef.current.disconnect(); socketRef.current.disconnect();
socketRef.current = null; socketRef.current = null;
@ -102,10 +95,8 @@ export function SocketProvider({ children }: { children: ReactNode }) {
return; return;
} }
// If already connected, don't reconnect again
if (socketRef.current?.connected) return; if (socketRef.current?.connected) return;
// Create socket connection with auth token
const socket = io(`${API_BASE_URL}/chat`, { const socket = io(`${API_BASE_URL}/chat`, {
auth: { token: `Bearer ${user.token}` }, auth: { token: `Bearer ${user.token}` },
transports: ["websocket", "polling"], transports: ["websocket", "polling"],
@ -113,75 +104,73 @@ export function SocketProvider({ children }: { children: ReactNode }) {
socketRef.current = socket; socketRef.current = socket;
/* ---------------- Server Events ---------------- */ //connection events
// When connection is successfully established
socket.on("connect", () => { socket.on("connect", () => {
setIsConnected(true); setIsConnected(true);
}); });
// When server confirms who you are
socket.on( socket.on(
"connected", "connected",
(data: { YourId: string; username: string; message: string }) => { (data: { YourId: string; username: string; message: string }) => {
setMySocketId(data.YourId); setMySocketId(data.YourId);
console.log("[Socket] Connected:", data.message); console.log("[Socket] Connected:", data.message);
}, }
); );
// When socket disconnects
socket.on("disconnect", () => { socket.on("disconnect", () => {
setIsConnected(false); setIsConnected(false);
setMySocketId(null); setMySocketId(null);
console.log("[Socket] Disconnected"); console.log("[Socket] Disconnected");
}); });
// If authentication fails
socket.on("authError", (data: { mesage?: string }) => { socket.on("authError", (data: { mesage?: string }) => {
console.error("[Socket] Auth error:", data.mesage); console.error("[Socket] Auth error:", data.mesage);
socket.disconnect(); socket.disconnect();
}); });
// General server messages
socket.on("serverNotice", (message: string) => { socket.on("serverNotice", (message: string) => {
console.log("[Socket] Server notice:", message); console.log("[Socket] Server notice:", message);
}); });
//- Room Events //room event
// When you join a room
socket.on("roomJoined", (data: { roomId: string; message: string }) => { socket.on("roomJoined", (data: { roomId: string; message: string }) => {
console.log("[Socket] Room joined:", data.message); console.log("[Socket] Room joined:", data.message);
}); });
// When you leave a room
socket.on("roomLeft", (data: { roomId: string }) => { socket.on("roomLeft", (data: { roomId: string }) => {
console.log("[Socket] Room left:", data.roomId); console.log("[Socket] Room left:", data.roomId);
}); });
// System messages inside a room
socket.on("roomNotice", (data: RoomNotice) => { socket.on("roomNotice", (data: RoomNotice) => {
setNotices((prev) => [...prev, data]); setNotices((prev) => [...prev, data]);
}); });
// P2P room ready — backend already did server-side join
socket.on("p2pReady", (data: P2PReadyData) => { socket.on("p2pReady", (data: P2PReadyData) => {
console.log("[Socket] P2P Ready:", data.message); console.log("[Socket] P2P Ready:", data.message);
setP2pReady(data); setP2pReady(data);
setCurrentRoom(data.roomId); setCurrentRoom(data.roomId);
}); });
// New chat message received //new message
socket.on("roomMessage", (data: ChatMessage) => { socket.on("roomMessage", (data: ChatMessage) => {
setMessages((prev) => [...prev, data]); setMessages((prev) => {
// Prevent duplicate messages
if (data._id && prev.find((m) => m._id === data._id)) {
return prev;
}
return [...prev, data];
});
}); });
// Room-related errors
socket.on("roomError", (data: { message: string }) => { socket.on("roomError", (data: { message: string }) => {
console.error("[Socket] Room error:", data.message); console.error("[Socket] Room error:", data.message);
}); });
// Cleanup socket when component unmounts or token changes
return () => { return () => {
socket.disconnect(); socket.disconnect();
socketRef.current = null; socketRef.current = null;
@ -190,40 +179,35 @@ export function SocketProvider({ children }: { children: ReactNode }) {
}; };
}, [user?.token]); }, [user?.token]);
// Actions you can call from UI //actions
// Join a chat room
const joinRoom = useCallback((roomId: string) => { const joinRoom = useCallback((roomId: string) => {
socketRef.current?.emit("joinRoom", { roomId }); socketRef.current?.emit("joinRoom", { roomId });
}, []); }, []);
// Leave a chat room
const leaveRoom = useCallback((roomId: string) => { const leaveRoom = useCallback((roomId: string) => {
socketRef.current?.emit("leaveRoom", { roomId }); socketRef.current?.emit("leaveRoom", { roomId });
}, []); }, []);
// Send a message to a room
const sendMessage = useCallback((roomId: string, text: string) => { const sendMessage = useCallback((roomId: string, text: string) => {
socketRef.current?.emit("roomMessage", { roomId, text }); socketRef.current?.emit("roomMessage", { roomId, text });
}, []); }, []);
// Start a P2P conversation via socket
const startP2P = useCallback((targetUserId: string) => { const startP2P = useCallback((targetUserId: string) => {
console.log(targetUserId);
socketRef.current?.emit("startP2P", { 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(() => { const clearP2PReady = useCallback(() => {
setP2pReady(null); setP2pReady(null);
}, []); }, []);
//provider
return ( return (
<SocketContext.Provider <SocketContext.Provider
value={{ value={{
@ -235,6 +219,7 @@ export function SocketProvider({ children }: { children: ReactNode }) {
sendMessage, sendMessage,
startP2P, startP2P,
messages, messages,
setMessages,
notices, notices,
currentRoom, currentRoom,
setCurrentRoom, setCurrentRoom,
@ -248,11 +233,10 @@ export function SocketProvider({ children }: { children: ReactNode }) {
); );
} }
// Hook to use socket context //hook
// Custom hook to access socket anywhere in app
export function useSocket() { export function useSocket() {
const ctx = useContext(SocketContext); const ctx = useContext(SocketContext);
if (!ctx) throw new Error("useSocket must be used inside <SocketProvider>"); if (!ctx) throw new Error("useSocket must be used inside <SocketProvider>");
return ctx; return ctx;
} }

View File

@ -77,9 +77,8 @@ export async function removeParticipant(convId: string, userId: string): Promise
return res.json(); return res.json();
} }
export async function fetchMessages(roomId: string): Promise<any[]> { export async function fetchMessages(convId: string): Promise<any[]> {
if (!roomId) return []; const res = await fetch(`${API_BASE_URL}/messages/${convId}`);
const res = await fetch(`${API_BASE_URL}/messages?roomId=${roomId}`); if (!res.ok) throw new Error("Failed to fetch messages");
if (!res.ok) return [];
return res.json(); return res.json();
} }