feat(dm): display active conversations in direct message sidebar

This commit is contained in:
Chneemann 2026-09-05 12:57:21 +02:00
parent fb5b007861
commit f312316400
No known key found for this signature in database
6 changed files with 210 additions and 86 deletions

View file

@ -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<JSX.Element>} 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 (
<ServerProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<AppSidebar servers={userServers} user={currentUser} />
<AppSidebar
servers={userServers}
conversations={formattedConversations}
user={currentUser}
/>
<div className="flex-1 flex min-w-0">{children}</div>
<MemberSidebar />
</div>

View file

@ -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<JSX.Element>} 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 (
<div className="flex flex-col h-full w-full bg-background p-4">
{session?.user?.id ? (
<FriendsView
currentUserId={session.user.id}
initialFriendships={initialFriendships}
/>
) : (
<>
<AppHeader />
<div className="flex flex-col items-center justify-center h-full text-center">
<h2 className="text-xl font-bold text-white mb-2">Welcome back!</h2>
<p className="text-muted max-w-sm">
Please sign in to view your friends list or start a conversation.
</p>
</div>
</>
)}
<FriendsView
currentUserId={currentUserId}
initialFriendships={rawFriendships as Friendship[]}
/>
</div>
);
}

View file

@ -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) {
>
<div className="flex flex-1 min-h-0 w-78">
<ServerSidebar servers={servers} />
<ChannelSidebar />
{!activeServer ? (
<DirectMessageSidebar conversations={conversations} />
) : (
<ChannelSidebar />
)}
</div>
<div className="w-78">
<UserPanel user={user} />
@ -82,7 +103,11 @@ export function AppSidebar({ servers, user }: AppSidebarProps) {
>
<div className="flex flex-1 min-h-0 w-full">
<ServerSidebar servers={servers} />
<ChannelSidebar />
{!activeServer ? (
<DirectMessageSidebar conversations={conversations} />
) : (
<ChannelSidebar />
)}
</div>
<UserPanel user={user} />
</div>

View file

@ -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 <DirectMessageSidebar />;
}
return (
<>
<div className="flex-1 w-full bg-surface/50 border-r border-background flex flex-col h-full min-w-0 overflow-hidden">

View file

@ -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 (
<div className="flex-1 w-full md:w-60 bg-surface/50 border-r border-background flex flex-col h-full shrink-0">
@ -38,19 +68,52 @@ export function DirectMessageSidebar() {
<div className="space-y-1">
<Link
href="/"
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm text-white bg-accent/20 font-medium transition-all"
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all",
pathname === "/"
? "text-white bg-accent/20"
: "text-muted hover:text-white hover:bg-surface/50",
)}
>
<span>Friends</span>
<Users className="w-4 h-4" />
<span className="font-bold">Friends</span>
</Link>
</div>
<div>
<div className="text-xs font-semibold text-muted px-2 mb-2 uppercase tracking-wider">
Direct Messages
</div>
<div className="text-sm text-muted px-2 py-1 italic">
No active chats
All Messages
</div>
{conversations.length === 0 ? (
<div className="text-sm text-muted px-2 py-1 italic">
No active chats
</div>
) : (
<div className="space-y-0.5">
{conversations.map((c) => {
const isActive = pathname === `/dm/${c.id}`;
return (
<Link
key={c.id}
href={`/dm/${c.id}`}
className={clsx(
"flex items-center gap-3 px-2 py-1.5 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-accent/20 text-white"
: "text-muted hover:text-white hover:bg-surface/50",
)}
>
{/* UserAvatar Integration */}
<UserAvatar user={c.partner} size="xs" />
<span className="truncate">{c.partner.username}</span>
</Link>
);
})}
</div>
)}
</div>
</div>
</div>

View file

@ -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<AvatarSize, { container: string; badge: string }> = {
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" },
};