From f312316400422f0dba3045012ebc9c2a6ce9b6a5 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Sat, 5 Sep 2026 12:57:21 +0200 Subject: [PATCH] feat(dm): display active conversations in direct message sidebar --- app/(app)/layout.tsx | 74 +++++++++++++---- app/(app)/page.tsx | 90 ++++++++++----------- components/layout/AppSidebar.tsx | 33 +++++++- components/sidebar/ChannelSidebar.tsx | 11 ++- components/sidebar/DirectMessageSidebar.tsx | 85 ++++++++++++++++--- components/ui/UserAvatar.tsx | 3 +- 6 files changed, 210 insertions(+), 86 deletions(-) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index b24d2ef..5508dd4 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,50 +1,92 @@ /** * @file app/(app)/layout.tsx - * @description Root application layout wrapping sidebars and main workspace content within the ServerProvider context. + * @description Main application layout component that handles user authentication guards, parallel data loading for servers and direct messages, and wraps the app with navigation sidebars and server context. */ import { auth } from "@/auth"; import { db } from "@/db"; -import { users } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { users, conversations } from "@/db/schema"; +import { eq, or } from "drizzle-orm"; import { getUserServers } from "@/lib/services/server.service"; import { AppSidebar } from "@/components/layout/AppSidebar"; import { MemberSidebar } from "@/components/layout/MemberSidebar"; import { ServerProvider } from "@/lib/context/ServerContext"; import { redirect } from "next/navigation"; +/** + * Renders the primary application layout with authentication checks, database fetching, and sidebar structure. + * + * @async + * @function AppLayout + * @param {Object} props - The layout properties. + * @param {React.ReactNode} props.children - The nested child route content to be rendered within the layout. + * @returns {Promise} The rendered application layout container. + */ export default async function AppLayout({ children, }: { children: React.ReactNode; }) { const session = await auth(); + const currentUserId = session?.user?.id; - if (!session?.user?.id) { + // Single Auth Guard + if (!currentUserId) { redirect("/login"); } - const [currentUser] = await db - .select({ - id: users.id, - username: users.username, - color: users.color, - status: users.status, - }) - .from(users) - .where(eq(users.id, session.user.id)) - .limit(1); + // Parallel Loading: User Details, Servers & DM-Conversations + const [[currentUser], userServers, userConversations] = await Promise.all([ + db + .select({ + id: users.id, + username: users.username, + color: users.color, + status: users.status, + }) + .from(users) + .where(eq(users.id, currentUserId)) + .limit(1), + getUserServers(currentUserId), + db.query.conversations.findMany({ + where: or( + eq(conversations.userOneId, currentUserId), + eq(conversations.userTwoId, currentUserId), + ), + with: { + userOne: true, + userTwo: true, + }, + }), + ]); if (!currentUser) { redirect("/login"); } - const userServers = await getUserServers(session.user.id); + // Transform conversations to isolate the respective conversation partner + const formattedConversations = userConversations.map((c) => { + const partner = c.userOne.id === currentUserId ? c.userTwo : c.userOne; + + return { + id: c.id, + partner: { + id: partner.id, + username: partner.username, + color: partner.color, + status: partner.status, + }, + }; + }); return (
- +
{children}
diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 81f2126..f61018e 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,69 +1,63 @@ /** * @file app/(app)/page.tsx - * @description Main application layout page featuring the FriendsView. + * @description Main application page component that performs user authentication checks, fetches raw friendship data from the database, and renders the friends management view. */ import { auth } from "@/auth"; import { db } from "@/db"; import { friendships } from "@/db/schema"; import { or, eq } from "drizzle-orm"; -import { AppHeader } from "@/components/layout/AppHeader"; -import { FriendsView, Friendship } from "@/components/friends/FriendsView"; +import { redirect } from "next/navigation"; +import { FriendsView, type Friendship } from "@/components/friends/FriendsView"; +/** + * Asynchronously renders the main application page for authenticated users. + * + * @async + * @function AppPage + * @returns {Promise} The rendered application page container with the FriendsView component. + */ export default async function AppPage() { const session = await auth(); + const currentUserId = session?.user?.id; - let initialFriendships: Friendship[] = []; + // Single Auth Guard + if (!currentUserId) { + redirect("/login"); + } - if (session?.user?.id) { - const currentUserId = session.user.id; - - const rawFriendships = await db.query.friendships.findMany({ - where: or( - eq(friendships.senderId, currentUserId), - eq(friendships.receiverId, currentUserId), - ), - with: { - sender: { - columns: { - id: true, - username: true, - color: true, - status: true, - }, - }, - receiver: { - columns: { - id: true, - username: true, - color: true, - status: true, - }, + // Retrieve all of the user's friendships + const rawFriendships = await db.query.friendships.findMany({ + where: or( + eq(friendships.senderId, currentUserId), + eq(friendships.receiverId, currentUserId), + ), + with: { + sender: { + columns: { + id: true, + username: true, + color: true, + status: true, }, }, - }); - - initialFriendships = rawFriendships as Friendship[]; - } + receiver: { + columns: { + id: true, + username: true, + color: true, + status: true, + }, + }, + }, + }); return (
- {session?.user?.id ? ( - - ) : ( - <> - -
-

Welcome back!

-

- Please sign in to view your friends list or start a conversation. -

-
- - )} +
); } diff --git a/components/layout/AppSidebar.tsx b/components/layout/AppSidebar.tsx index b8a86bc..daf597e 100644 --- a/components/layout/AppSidebar.tsx +++ b/components/layout/AppSidebar.tsx @@ -9,9 +9,16 @@ import { useSidebarStore } from "@/lib/stores/useSidebarStore"; import { ServerSidebar } from "@/components/sidebar/ServerSidebar"; import { ChannelSidebar } from "@/components/sidebar/ChannelSidebar"; import { UserPanel } from "@/components/sidebar/UserPanel"; -import type { ServerWithChannels } from "@/lib/context/ServerContext"; +import { + useActiveServer, + type ServerWithChannels, +} from "@/lib/context/ServerContext"; import type { UserStatus } from "@/db/schema"; import { clsx } from "clsx"; +import { + DirectMessageSidebar, + SidebarConversation, +} from "../sidebar/DirectMessageSidebar"; /** * Properties representing the user in the sidebar. @@ -32,10 +39,12 @@ export interface SidebarUser { * * @interface AppSidebarProps * @property {ServerWithChannels[]} servers - List of available servers including their channels. + * @property {SidebarConversation[]} conversations - List of direct message conversations. * @property {SidebarUser} user - Information about the currently authenticated user. */ interface AppSidebarProps { servers: ServerWithChannels[]; + conversations: SidebarConversation[]; user: SidebarUser; } @@ -43,10 +52,18 @@ interface AppSidebarProps { * Renders the responsive application sidebar containing server navigation, channel lists, and user profile. * * @param {AppSidebarProps} props - The component props. + * @param {ServerWithChannels[]} props.servers - List of available servers including their channels. + * @param {SidebarConversation[]} props.conversations - List of direct message conversations. + * @param {SidebarUser} props.user - Information about the currently authenticated user. * @returns {JSX.Element} The rendered mobile overlay and responsive sidebar structure. */ -export function AppSidebar({ servers, user }: AppSidebarProps) { +export function AppSidebar({ + servers, + user, + conversations = [], +}: AppSidebarProps) { const { isNavOpen, closeAll } = useSidebarStore(); + const { activeServer } = useActiveServer(); return ( <> @@ -59,7 +76,11 @@ export function AppSidebar({ servers, user }: AppSidebarProps) { >
- + {!activeServer ? ( + + ) : ( + + )}
@@ -82,7 +103,11 @@ export function AppSidebar({ servers, user }: AppSidebarProps) { >
- + {!activeServer ? ( + + ) : ( + + )}
diff --git a/components/sidebar/ChannelSidebar.tsx b/components/sidebar/ChannelSidebar.tsx index faedd61..d96d6dd 100644 --- a/components/sidebar/ChannelSidebar.tsx +++ b/components/sidebar/ChannelSidebar.tsx @@ -9,7 +9,6 @@ import { useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useActiveServer } from "@/lib/context/ServerContext"; -import { DirectMessageSidebar } from "@/components/sidebar/DirectMessageSidebar"; import { useSidebarStore } from "@/lib/stores/useSidebarStore"; import { CreateChannelModal } from "@/components/modals/CreateChannelModal"; import { EditChannelModal } from "@/components/modals/EditChannelModal"; @@ -19,7 +18,7 @@ import type { Channel } from "@/db/schema"; /** * Renders the channel sidebar for the active server with text channel lists, creation triggers, and settings handlers. * - * @returns {JSX.Element} The rendered channel sidebar container. + * @returns {JSX.Element | null} The rendered channel sidebar container or null if no active server exists. */ export function ChannelSidebar() { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); @@ -30,6 +29,10 @@ export function ChannelSidebar() { const { activeServer } = useActiveServer(); const { closeNav, toggleNav } = useSidebarStore(); + if (!activeServer) { + return null; + } + /** * Handles channel selection clicks, automatically closing the mobile navigation drawer on smaller screens. * @@ -56,10 +59,6 @@ export function ChannelSidebar() { setEditingChannel(channel); }; - if (!activeServer) { - return ; - } - return ( <>
diff --git a/components/sidebar/DirectMessageSidebar.tsx b/components/sidebar/DirectMessageSidebar.tsx index 83cb0b8..dd4342f 100644 --- a/components/sidebar/DirectMessageSidebar.tsx +++ b/components/sidebar/DirectMessageSidebar.tsx @@ -6,17 +6,47 @@ "use client"; import { useSidebarStore } from "@/lib/stores/useSidebarStore"; -import { PanelLeftClose } from "lucide-react"; +import { PanelLeftClose, Users } from "lucide-react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { clsx } from "clsx"; +import { UserAvatar } from "@/components/ui/UserAvatar"; +import { FriendUser } from "../friends/FriendsView"; /** - * DirectMessageSidebar component that renders navigation for direct messages and friends. - * Includes a header with collapsible controls and links for viewing chat channels and active conversations. + * Represents a direct message conversation in the sidebar. * - * @returns {JSX.Element} The direct message sidebar component layout. + * @interface SidebarConversation + * @property {string} id - The unique identifier of the conversation. + * @property {FriendUser} partner - The other user participating in the conversation. */ -export function DirectMessageSidebar() { +export interface SidebarConversation { + id: string; + partner: FriendUser; +} + +/** + * Properties for the DirectMessageSidebar component. + * + * @interface DirectMessageSidebarProps + * @property {SidebarConversation[]} [conversations=[]] - Array of active direct message conversations. + */ +interface DirectMessageSidebarProps { + conversations?: SidebarConversation[]; +} + +/** + * Renders the direct message sidebar with navigation options, friends view link, and a list of active DM chats. + * + * @param {DirectMessageSidebarProps} props - The component props. + * @param {SidebarConversation[]} [props.conversations=[]] - Array of active direct message conversations. + * @returns {JSX.Element} The rendered direct message sidebar. + */ +export function DirectMessageSidebar({ + conversations = [], +}: DirectMessageSidebarProps) { const { toggleNav } = useSidebarStore(); + const pathname = usePathname(); return (
@@ -38,19 +68,52 @@ export function DirectMessageSidebar() {
- Friends + + Friends
- Direct Messages -
-
- No active chats + All Messages
+ + {conversations.length === 0 ? ( +
+ No active chats +
+ ) : ( +
+ {conversations.map((c) => { + const isActive = pathname === `/dm/${c.id}`; + + return ( + + {/* UserAvatar Integration */} + + + {c.partner.username} + + ); + })} +
+ )}
diff --git a/components/ui/UserAvatar.tsx b/components/ui/UserAvatar.tsx index 681cdfd..a80b606 100644 --- a/components/ui/UserAvatar.tsx +++ b/components/ui/UserAvatar.tsx @@ -15,7 +15,7 @@ import { clsx } from "clsx"; * * @type AvatarSize */ -type AvatarSize = "sm" | "md"; +type AvatarSize = "xs" | "sm" | "md"; /** * User object properties required to render the avatar. @@ -46,6 +46,7 @@ interface UserAvatarProps { } const SIZE_MAP: Record = { + xs: { container: "w-6 h-6 text-xs", badge: "w-1.5 h-1.5" }, sm: { container: "w-8 h-8 text-xs", badge: "w-2.5 h-2.5" }, md: { container: "w-10 h-10 text-sm", badge: "w-3 h-3" }, };