added the date time and proper conversation history
This commit is contained in:
parent
04128f0205
commit
d2ea0ff9cb
@ -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">
|
||||||
|
{/* Create Group Dropdown */}
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
onClick={() => setActiveTab(activeTab === "create" ? "rooms" : "create")}
|
onClick={() => setShowCreateDropdown((prev) => !prev)}
|
||||||
title="Create room"
|
title="Create group"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</Button>
|
</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
|
<div
|
||||||
|
key={room.id}
|
||||||
onClick={() => handleSelectRoom(room)}
|
onClick={() => handleSelectRoom(room)}
|
||||||
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition flex-1 min-w-0
|
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition
|
||||||
${isActive
|
${isActive ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-full ${isActive ? "bg-white/20" : "bg-primary/10"
|
className={`w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-full ${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"}`}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<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.participants?.length ?? 0} members` : "Direct Message"}
|
{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>
|
</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(
|
||||||
|
(u) =>
|
||||||
!debouncedUserSearch.trim() ||
|
!debouncedUserSearch.trim() ||
|
||||||
u.name?.toLowerCase().includes(debouncedUserSearch.toLowerCase())
|
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 &&
|
||||||
|
roomMessages.length === 0 &&
|
||||||
|
roomNotices.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
No messages yet. Say something!
|
No messages yet. Say something!
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{roomMessages.map((msg, idx) => {
|
{(() => {
|
||||||
const isMe = msg.senderId === mySocketId;
|
let lastDate = "";
|
||||||
|
|
||||||
|
return roomMessages.map((msg, idx) => {
|
||||||
|
const msgDate = formatDate(msg.createdAt!);
|
||||||
|
const showDate = msgDate !== lastDate;
|
||||||
|
lastDate = msgDate;
|
||||||
|
|
||||||
|
const isMe =
|
||||||
|
msg.senderId === user?.userId ||
|
||||||
|
msg.senderId === mySocketId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={msg._id || idx}>
|
||||||
key={`msg-${idx}`}
|
{/* DATE SEPARATOR */}
|
||||||
className={`flex ${isMe ? "justify-end" : "justify-start"}`}
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MESSAGE */}
|
||||||
|
<div className={`flex ${isMe ? "justify-end" : "justify-start"}`}>
|
||||||
<div className="flex flex-col max-w-xs">
|
<div className="flex flex-col max-w-xs">
|
||||||
{!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 ${isMe ? "bg-primary text-primary-foreground" : "bg-muted"
|
className={`px-4 py-2 rounded-xl ${isMe
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.text}
|
<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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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,9 +233,8 @@ 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>");
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user