942 lines
31 KiB
TypeScript
942 lines
31 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useMemo } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useAuth } from "@/context/AuthContext";
|
|
import { useSocket, type ChatMessage, type RoomNotice } from "@/context/SocketContext";
|
|
import {
|
|
fetchConversations,
|
|
createConversation,
|
|
fetchAllUsers,
|
|
ConversationType,
|
|
addParticipant,
|
|
removeParticipant,
|
|
fetchMessages,
|
|
subscribeToSSE,
|
|
} from "@/lib/api";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Send,
|
|
Phone,
|
|
Video,
|
|
Plus,
|
|
LogOut,
|
|
Loader2,
|
|
Users,
|
|
User as UserIcon,
|
|
UserMinus,
|
|
UserPlus,
|
|
ChevronDown,
|
|
} from "lucide-react";
|
|
|
|
interface Room {
|
|
id: string;
|
|
name: string;
|
|
isGroup: boolean;
|
|
participants: { _id: string; name: string }[];
|
|
}
|
|
|
|
export default function ChatPage() {
|
|
const router = useRouter();
|
|
const { user, isLoading: authLoading, logout } = useAuth();
|
|
const {
|
|
isConnected,
|
|
mySocketId,
|
|
joinRoom,
|
|
leaveRoom,
|
|
sendMessage,
|
|
startP2P,
|
|
messages,
|
|
notices,
|
|
currentRoom,
|
|
setCurrentRoom,
|
|
clearMessages,
|
|
p2pReady,
|
|
setMessages,
|
|
clearP2PReady,
|
|
} = useSocket();
|
|
|
|
const [text, setText] = useState("");
|
|
const [roomSearch, setRoomSearch] = useState("");
|
|
const [userSearch, setUserSearch] = useState("");
|
|
const [activeTab, setActiveTab] = useState<"rooms" | "users">("rooms");
|
|
const [showCreateDropdown, setShowCreateDropdown] = useState(false);
|
|
const [newRoomName, setNewRoomName] = useState("");
|
|
const [rooms, setRooms] = useState<Room[]>([]);
|
|
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 debouncedUserSearch = useDebounce(userSearch, 300);
|
|
|
|
// Auth guard
|
|
useEffect(() => {
|
|
if (!authLoading && !user) {
|
|
router.push("/login");
|
|
}
|
|
}, [authLoading, user, router]);
|
|
|
|
// Fetch rooms & Auto-join
|
|
useEffect(() => {
|
|
if (!isConnected || !user?.userId || allUsers.length === 0) return;
|
|
|
|
fetchConversations(user.userId)
|
|
.then((data) => {
|
|
const loadedRooms = Array.isArray(data)
|
|
? data.map((c) => {
|
|
const isGroup = c.type === "group";
|
|
let roomName = c.name;
|
|
|
|
if (!isGroup) {
|
|
let otherUserName = "Unknown User";
|
|
if (typeof c.participants?.[0] === "object") {
|
|
const otherUser = c.participants.find(
|
|
(p: any) => p._id !== user.userId
|
|
);
|
|
otherUserName = otherUser?.name || otherUserName;
|
|
} else {
|
|
const otherUserId = c.participants?.find(
|
|
(id: string) => id !== user.userId
|
|
);
|
|
const otherUser = allUsers.find((u) => u._id === otherUserId);
|
|
otherUserName = otherUser?.name || otherUserName;
|
|
}
|
|
roomName = otherUserName;
|
|
}
|
|
|
|
return {
|
|
id: c._id || c.id,
|
|
name: roomName || "Unnamed Group",
|
|
isGroup,
|
|
participants: c.participants || [],
|
|
};
|
|
})
|
|
: [];
|
|
|
|
setRooms(loadedRooms);
|
|
|
|
if (!currentRoom && loadedRooms.length > 0) {
|
|
const firstRoom = loadedRooms[0];
|
|
setCurrentRoom(firstRoom.id);
|
|
joinRoom(firstRoom.id);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("Failed to fetch conversations", err.message);
|
|
});
|
|
}, [isConnected, user, allUsers, currentRoom, joinRoom, setCurrentRoom]);
|
|
|
|
// Fetch messages for current room
|
|
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,
|
|
}));
|
|
|
|
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(() => {
|
|
if (!user) return;
|
|
fetchAllUsers()
|
|
.then((data) => {
|
|
if (Array.isArray(data)) {
|
|
setAllUsers(data.filter((u: any) => u.name !== user.username));
|
|
}
|
|
})
|
|
.catch(console.error);
|
|
}, [user]);
|
|
|
|
// ─── SSE: real-time group invite / removal ───────────────────────────────
|
|
useEffect(() => {
|
|
if (!user?.userId) return;
|
|
|
|
console.log("[SSE HOOK] Initializing for user:", user.userId);
|
|
|
|
const cleanup = subscribeToSSE(user.userId, {
|
|
// Someone added this user to a group conversation
|
|
onGroupInvite: ({ conversation }) => {
|
|
console.log("[SSE HOOK] onGroupInvite TRIGGERED");
|
|
|
|
if (!conversation) {
|
|
console.warn("[SSE HOOK] groupInvite missing conversation payload");
|
|
return;
|
|
}
|
|
|
|
console.log("[SSE HOOK] groupInvite payload:", conversation);
|
|
|
|
const convId: string = conversation._id ?? conversation.id;
|
|
const isGroup = conversation.type === "group";
|
|
|
|
console.log("[SSE HOOK] convId:", convId, "isGroup:", isGroup);
|
|
|
|
// Resolve display name
|
|
let roomName = conversation.name || "Unnamed Group";
|
|
|
|
if (!isGroup) {
|
|
const other = (conversation.participants ?? []).find(
|
|
(p: any) => (p._id ?? p) !== user.userId
|
|
);
|
|
roomName = other?.name ?? "Direct Message";
|
|
}
|
|
|
|
console.log("[SSE HOOK] roomName resolved:", roomName);
|
|
|
|
// Hydrate participants array
|
|
const participants: { _id: string; name: string }[] = (
|
|
conversation.participants ?? []
|
|
).map((p: any) =>
|
|
typeof p === "object"
|
|
? { _id: p._id, name: p.name ?? p._id }
|
|
: { _id: p, name: p }
|
|
);
|
|
|
|
console.log("[SSE HOOK] participants:", participants);
|
|
|
|
const newRoom: Room = {
|
|
id: convId,
|
|
name: roomName,
|
|
isGroup,
|
|
participants,
|
|
};
|
|
|
|
console.log("[SSE HOOK] newRoom created:", newRoom);
|
|
|
|
// Add to sidebar if not already there
|
|
setRooms((prev) => {
|
|
const exists = prev.find((r) => r.id === convId);
|
|
if (exists) {
|
|
console.log("[SSE HOOK] room already exists, skipping add");
|
|
return prev;
|
|
}
|
|
|
|
console.log("[SSE HOOK] adding new room to sidebar");
|
|
return [...prev, newRoom];
|
|
});
|
|
|
|
console.log("[SSE HOOK] joining room:", convId);
|
|
joinRoom(convId);
|
|
},
|
|
|
|
// This user was removed from a group
|
|
onGroupRemoved: ({ conversation }) => {
|
|
console.log("[SSE HOOK] onGroupRemoved TRIGGERED");
|
|
|
|
if (!conversation) {
|
|
console.warn("[SSE HOOK] groupRemoved missing conversation payload");
|
|
return;
|
|
}
|
|
|
|
console.log("[SSE HOOK] groupRemoved payload:", conversation);
|
|
|
|
const roomId = conversation._id ?? conversation.id;
|
|
|
|
console.log("[SSE HOOK] removing roomId:", roomId);
|
|
|
|
// Leave socket room
|
|
leaveRoom(roomId);
|
|
console.log("[SSE HOOK] left room:", roomId);
|
|
|
|
// Remove from sidebar
|
|
setRooms((prev) => {
|
|
const filtered = prev.filter((r) => r.id !== roomId);
|
|
console.log("[SSE HOOK] updated rooms after removal:", filtered);
|
|
return filtered;
|
|
});
|
|
|
|
// Clear selection if the removed room was active
|
|
if (currentRoom === roomId) {
|
|
console.log("[SSE HOOK] clearing current room selection");
|
|
setCurrentRoom(null);
|
|
}
|
|
|
|
// Drop its messages from state
|
|
setMessages((prev) => {
|
|
const filtered = prev.filter((m) => m.roomId !== roomId);
|
|
console.log("[SSE HOOK] messages cleaned for room:", roomId);
|
|
return filtered;
|
|
});
|
|
},
|
|
});
|
|
|
|
return cleanup;
|
|
}, [user?.userId, joinRoom, leaveRoom, setCurrentRoom, setMessages]);
|
|
|
|
|
|
// React to P2P Ready event
|
|
useEffect(() => {
|
|
if (!p2pReady) return;
|
|
const targetUser = allUsers.find((u) => u._id === p2pReady.targetUserId);
|
|
const roomName = targetUser?.name || "Direct Message";
|
|
|
|
setRooms((prev) => {
|
|
if (prev.find((r) => r.id === p2pReady.roomId)) return prev;
|
|
return [
|
|
...prev,
|
|
{ id: p2pReady.roomId, name: roomName, isGroup: false, participants: [] },
|
|
];
|
|
});
|
|
|
|
setActiveTab("rooms");
|
|
clearP2PReady();
|
|
}, [p2pReady, allUsers, clearP2PReady]);
|
|
|
|
// Scroll to bottom on new messages
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages, notices]);
|
|
|
|
// 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(() => {
|
|
if (!debouncedRoomSearch.trim()) return rooms;
|
|
return rooms.filter((r) =>
|
|
r.name.toLowerCase().includes(debouncedRoomSearch.toLowerCase())
|
|
);
|
|
}, [rooms, debouncedRoomSearch]);
|
|
|
|
// Messages for current room
|
|
const roomMessages = useMemo(
|
|
() => messages.filter((m) => m.roomId === currentRoom),
|
|
[messages, currentRoom]
|
|
);
|
|
|
|
const roomNotices = useMemo(
|
|
() => notices.filter((n) => n.roomId === currentRoom),
|
|
[notices, currentRoom]
|
|
);
|
|
|
|
// Handlers
|
|
const handleSelectRoom = (room: Room) => {
|
|
if (currentRoom === room.id) return;
|
|
if (currentRoom) leaveRoom(currentRoom);
|
|
setCurrentRoom(room.id);
|
|
joinRoom(room.id);
|
|
setShowManageDropdown(false);
|
|
};
|
|
|
|
const handleSend = () => {
|
|
if (!text.trim() || !currentRoom) return;
|
|
sendMessage(currentRoom, text.trim());
|
|
setText("");
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
const handleCreateRoom = async () => {
|
|
const name = newRoomName.trim();
|
|
if (!name) return;
|
|
try {
|
|
const created = await createConversation({
|
|
name,
|
|
type: ConversationType.GROUP,
|
|
participants: [user!.userId],
|
|
});
|
|
const newRoom: Room = {
|
|
id: created._id || created.id,
|
|
name: created.name || name,
|
|
isGroup: true,
|
|
participants: created.participants || [],
|
|
};
|
|
setRooms((prev) =>
|
|
prev.find((r) => r.id === newRoom.id) ? prev : [...prev, newRoom]
|
|
);
|
|
setNewRoomName("");
|
|
setShowCreateDropdown(false);
|
|
setActiveTab("rooms");
|
|
handleSelectRoom(newRoom);
|
|
} catch (err) {
|
|
console.error("Error creating room", err);
|
|
}
|
|
};
|
|
|
|
const handleAddParticipant = async (convId: string, userId: string) => {
|
|
try {
|
|
const updated = await addParticipant(convId, userId);
|
|
|
|
const rawParticipants: any[] = updated.participants || [];
|
|
const hydrated = rawParticipants.map((p: any) => {
|
|
if (typeof p === "string") {
|
|
const found = allUsers.find((u) => u._id === p);
|
|
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) {
|
|
console.error("Add participant failed:", err.message);
|
|
}
|
|
};
|
|
|
|
const handleRemoveParticipant = async (convId: string, userId: string) => {
|
|
try {
|
|
const updated = await removeParticipant(convId, userId);
|
|
|
|
const rawParticipants: any[] = updated.participants || [];
|
|
const hydrated = rawParticipants.map((p: any) => {
|
|
if (typeof p === "string") {
|
|
const found = allUsers.find((u) => u._id === p);
|
|
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) {
|
|
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) => {
|
|
if (!targetUser?._id) return;
|
|
startP2P(targetUser._id);
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
if (currentRoom) leaveRoom(currentRoom);
|
|
clearMessages();
|
|
logout();
|
|
};
|
|
|
|
// Loading / Auth guard
|
|
if (authLoading || !user) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const selectedRoom = rooms.find((r) => r.id === currentRoom);
|
|
|
|
const manageParticipants = selectedRoom?.participants ?? [];
|
|
const addableUsers = allUsers.filter(
|
|
(u) => !manageParticipants.some((p: any) => (p._id ?? p) === u._id)
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-screen">
|
|
{ /* LEFT SIDEBAR */}
|
|
<div className="w-1/3 border-r flex flex-col bg-background">
|
|
{/* HEADER */}
|
|
<div className="flex items-center justify-between p-4">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-xl font-semibold">Chats</h2>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
{/* Create Group Dropdown */}
|
|
<div className="relative" ref={dropdownRef}>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="rounded-full"
|
|
onClick={() => setShowCreateDropdown((prev) => !prev)}
|
|
title="Create group"
|
|
>
|
|
<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
|
|
size="icon"
|
|
variant="ghost"
|
|
className="rounded-full"
|
|
onClick={handleLogout}
|
|
title="Logout"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* USER INFO */}
|
|
<div className="px-4 pb-2 text-sm text-muted-foreground">
|
|
Logged in as{" "}
|
|
<span className="font-medium text-foreground">{user.username}</span>
|
|
</div>
|
|
|
|
{/* TABS */}
|
|
<div className="flex gap-4 px-4 text-sm font-medium border-b pb-2">
|
|
<button
|
|
onClick={() => setActiveTab("rooms")}
|
|
className={`pb-1 ${activeTab === "rooms"
|
|
? "text-primary border-b-2 border-primary"
|
|
: "text-muted-foreground"
|
|
}`}
|
|
>
|
|
Chats
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("users")}
|
|
className={`pb-1 ${activeTab === "users"
|
|
? "text-primary border-b-2 border-primary"
|
|
: "text-muted-foreground"
|
|
}`}
|
|
>
|
|
People
|
|
</button>
|
|
</div>
|
|
|
|
{/* ROOM LIST */}
|
|
{activeTab === "rooms" && (
|
|
<div className="flex flex-col flex-1 min-h-0">
|
|
<div className="p-4">
|
|
<Input
|
|
placeholder="Search rooms..."
|
|
value={roomSearch}
|
|
onChange={(e) => setRoomSearch(e.target.value)}
|
|
className="rounded-full bg-muted"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto px-2 space-y-1">
|
|
{filteredRooms.length === 0 && (
|
|
<p className="p-4 text-sm text-muted-foreground">
|
|
No conversations yet. Start a chat from People tab!
|
|
</p>
|
|
)}
|
|
|
|
{filteredRooms.map((room) => {
|
|
const isActive = currentRoom === room.id;
|
|
return (
|
|
<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
|
|
className={`w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-full ${isActive ? "bg-white/20" : "bg-primary/10"
|
|
}`}
|
|
>
|
|
{room.isGroup ? (
|
|
<Users
|
|
className={`w-5 h-5 ${isActive ? "text-white" : "text-primary"}`}
|
|
/>
|
|
) : (
|
|
<UserIcon
|
|
className={`w-5 h-5 ${isActive ? "text-white" : "text-primary"}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-hidden">
|
|
<h3 className="font-semibold truncate">{room.name}</h3>
|
|
<p className="text-xs opacity-70 truncate">
|
|
{room.isGroup
|
|
? `${room.participants?.length ?? 0} members`
|
|
: "Direct Message"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* PEOPLE LIST */}
|
|
{activeTab === "users" && (
|
|
<div className="flex flex-col flex-1 min-h-0">
|
|
<div className="p-4">
|
|
<Input
|
|
placeholder="Search people..."
|
|
value={userSearch}
|
|
onChange={(e) => setUserSearch(e.target.value)}
|
|
className="rounded-full bg-muted"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto px-2 space-y-1">
|
|
{allUsers.length === 0 ? (
|
|
<p className="p-4 text-sm text-muted-foreground">
|
|
No other users found.
|
|
</p>
|
|
) : (
|
|
allUsers
|
|
.filter(
|
|
(u) =>
|
|
!debouncedUserSearch.trim() ||
|
|
u.name
|
|
?.toLowerCase()
|
|
.includes(debouncedUserSearch.toLowerCase())
|
|
)
|
|
.map((u) => (
|
|
<div
|
|
key={u._id}
|
|
onClick={() => handleStartP2P(u)}
|
|
className="flex items-center gap-3 p-3 rounded-xl cursor-pointer transition hover:bg-muted"
|
|
>
|
|
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-primary/10">
|
|
<UserIcon className="w-5 h-5 text-primary" />
|
|
</div>
|
|
<div className="flex-1 overflow-hidden">
|
|
<h3 className="font-semibold truncate">{u.name}</h3>
|
|
<p className="text-xs opacity-70 truncate">
|
|
Click to chat
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* RIGHT CHAT */}
|
|
<div className="w-2/3 flex flex-col">
|
|
{/* HEADER */}
|
|
<div className="p-4 border-b font-semibold flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-primary/10">
|
|
{selectedRoom?.isGroup ? (
|
|
<Users className="w-5 h-5 text-primary" />
|
|
) : (
|
|
<UserIcon className="w-5 h-5 text-primary" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<span className="text-lg">
|
|
{selectedRoom?.name || "Select a room"}
|
|
</span>
|
|
{selectedRoom && (
|
|
<p className="text-xs text-muted-foreground font-normal">
|
|
{selectedRoom.isGroup
|
|
? `${selectedRoom.participants?.length ?? 0} members`
|
|
: "Direct Message"}{" "}
|
|
· {isConnected ? "Connected" : "Disconnected"}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right-side action buttons */}
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => console.log("Audio call")}
|
|
>
|
|
<Phone className="w-5 h-5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => console.log("Video call")}
|
|
>
|
|
<Video className="w-5 h-5" />
|
|
</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>
|
|
|
|
{/* MESSAGES */}
|
|
<div className="flex-1 p-4 space-y-2 overflow-y-auto">
|
|
{!currentRoom && (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
Select a room to start chatting
|
|
</div>
|
|
)}
|
|
|
|
{currentRoom &&
|
|
roomMessages.length === 0 &&
|
|
roomNotices.length === 0 && (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
No messages yet. Say something!
|
|
</div>
|
|
)}
|
|
|
|
{(() => {
|
|
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 (
|
|
<div key={msg._id || idx}>
|
|
{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={`flex ${isMe ? "justify-end" : "justify-start"}`}>
|
|
<div className="flex flex-col max-w-xs">
|
|
{!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>
|
|
<div className="text-[10px] text-right opacity-70 mt-1">
|
|
{formatTime(msg.createdAt!)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
})()}
|
|
|
|
{roomNotices.map((notice, idx) => (
|
|
<div key={`notice-${idx}`} className="flex justify-center">
|
|
<span className="text-xs text-muted-foreground bg-muted/50 px-3 py-1 rounded-full">
|
|
{notice.message}
|
|
</span>
|
|
</div>
|
|
))}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* INPUT */}
|
|
<div className="p-4 border-t flex gap-2">
|
|
<Input
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={
|
|
currentRoom
|
|
? `Message ${selectedRoom?.name ?? ""}...`
|
|
: "Select a room first..."
|
|
}
|
|
disabled={!currentRoom || !isConnected}
|
|
/>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!currentRoom || !isConnected || !text.trim()}
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |