refactor(members): introduce UserProvider to eliminate prop drilling for member profile popovers
All checks were successful
Deploy Waveform to VPS / deploy (push) Successful in 1m4s

This commit is contained in:
Chneemann 2026-09-10 10:20:41 +02:00
parent e2985c53a2
commit 3a119c5891
No known key found for this signature in database
9 changed files with 87 additions and 120 deletions

View file

@ -13,7 +13,6 @@ import { ChatInput } from "@/components/chat/ChatInput";
import { ChatMessages } from "@/components/chat/ChatMessages"; import { ChatMessages } from "@/components/chat/ChatMessages";
import type { MessageWithMember } from "@/components/chat/ChatItem"; import type { MessageWithMember } from "@/components/chat/ChatItem";
import { isValidUuid } from "@/lib/utils"; import { isValidUuid } from "@/lib/utils";
import { getUserFriendships } from "@/lib/services/friends.service";
import AppFooter from "@/components/layout/AppFooter"; import AppFooter from "@/components/layout/AppFooter";
/** Renders the direct message conversation page with header, message history, and input field. */ /** Renders the direct message conversation page with header, message history, and input field. */
@ -35,8 +34,8 @@ export default async function DirectMessagePage({
redirect("/login"); redirect("/login");
} }
// 3. Database Queries // 3. Database Queries (Freundschaften werden nun global im Layout geladen)
const [conversation, rawMessages, friendships] = await Promise.all([ const [conversation, rawMessages] = await Promise.all([
db.query.conversations.findFirst({ db.query.conversations.findFirst({
where: and( where: and(
eq(conversations.id, conversationId), eq(conversations.id, conversationId),
@ -57,7 +56,6 @@ export default async function DirectMessagePage({
}, },
orderBy: (dm, { asc }) => [asc(dm.createdAt)], orderBy: (dm, { asc }) => [asc(dm.createdAt)],
}), }),
getUserFriendships(session.user.id),
]); ]);
if (!conversation) { if (!conversation) {
@ -90,8 +88,6 @@ export default async function DirectMessagePage({
type="dm" type="dm"
name={partner.username} name={partner.username}
initialMessages={initialMessages} initialMessages={initialMessages}
currentUserId={session.user.id}
userFriendships={friendships}
/> />
<ChatInput <ChatInput

View file

@ -13,6 +13,7 @@ import { MemberSidebar } from "@/components/layout/MemberSidebar";
import { ServerProvider } from "@/lib/context/ServerContext"; import { ServerProvider } from "@/lib/context/ServerContext";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getUserFriendships } from "@/lib/services/friends.service"; import { getUserFriendships } from "@/lib/services/friends.service";
import { UserProvider } from "@/lib/context/UserContext";
/** Renders the primary application layout with authentication checks, database fetching, and sidebar structure. */ /** Renders the primary application layout with authentication checks, database fetching, and sidebar structure. */
export default async function AppLayout({ export default async function AppLayout({
@ -30,16 +31,7 @@ export default async function AppLayout({
// Parallel Loading: User Details, Servers & DM-Conversations // Parallel Loading: User Details, Servers & DM-Conversations
const [[currentUser], userServers, userConversations] = await Promise.all([ const [[currentUser], userServers, userConversations] = await Promise.all([
db db.select().from(users).where(eq(users.id, currentUserId)).limit(1),
.select({
id: users.id,
username: users.username,
color: users.color,
status: users.status,
})
.from(users)
.where(eq(users.id, currentUserId))
.limit(1),
getUserServers(currentUserId), getUserServers(currentUserId),
db.query.conversations.findMany({ db.query.conversations.findMany({
where: or( where: or(
@ -78,19 +70,18 @@ export default async function AppLayout({
]); ]);
return ( return (
<ServerProvider> <UserProvider currentUser={currentUser} friendships={friendships}>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground"> <ServerProvider>
<AppSidebar <div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
servers={userServers} <AppSidebar
conversations={formattedConversations} servers={userServers}
user={currentUser} conversations={formattedConversations}
/> user={currentUser}
<div className="flex-1 flex min-w-0">{children}</div> />
<MemberSidebar <div className="flex-1 flex min-w-0">{children}</div>
currentUserId={currentUserId} <MemberSidebar />
userFriendships={friendships} </div>
/> </ServerProvider>
</div> </UserProvider>
</ServerProvider>
); );
} }

View file

@ -12,7 +12,6 @@ import { getChannelById } from "@/lib/services/channel.service";
import { getChannelMessages } from "@/lib/services/message.service"; import { getChannelMessages } from "@/lib/services/message.service";
import { getServerById } from "@/lib/services/server.service"; import { getServerById } from "@/lib/services/server.service";
import { isValidUuid } from "@/lib/utils"; import { isValidUuid } from "@/lib/utils";
import { getUserFriendships } from "@/lib/services/friends.service";
import AppFooter from "@/components/layout/AppFooter"; import AppFooter from "@/components/layout/AppFooter";
/** Renders the channel chat view by validating parameters, checking user session, fetching channel data, friendships, and displaying headers, messages, and input controls. */ /** Renders the channel chat view by validating parameters, checking user session, fetching channel data, friendships, and displaying headers, messages, and input controls. */
@ -34,11 +33,10 @@ export default async function ChannelPage({
} }
// 3. Parallel Loading of Data // 3. Parallel Loading of Data
const [server, channel, channelMessages, friendships] = await Promise.all([ const [server, channel, channelMessages] = await Promise.all([
getServerById(serverId), getServerById(serverId),
getChannelById(channelId), getChannelById(channelId),
getChannelMessages(channelId), getChannelMessages(channelId),
getUserFriendships(session.user.id),
]); ]);
if (!channel || !server) { if (!channel || !server) {
@ -57,8 +55,6 @@ export default async function ChannelPage({
type="chat" type="chat"
name={channel.name} name={channel.name}
initialMessages={channelMessages} initialMessages={channelMessages}
currentUserId={session.user.id}
userFriendships={friendships}
/> />
<ChatInput <ChatInput

View file

@ -13,6 +13,7 @@ import { ChatItemActions } from "./ChatItemActions";
import { ChatItemEdit } from "./ChatItemEdit"; import { ChatItemEdit } from "./ChatItemEdit";
import { UserProfilePopover } from "../ui/UserProfilePopover"; import { UserProfilePopover } from "../ui/UserProfilePopover";
import { useActiveServer } from "@/lib/context/ServerContext"; import { useActiveServer } from "@/lib/context/ServerContext";
import { useUser } from "@/lib/context/UserContext";
/** Composite message type extending base database Message with channel/conversation details and member relation. */ /** Composite message type extending base database Message with channel/conversation details and member relation. */
export type MessageWithMember = Omit<Message, "channelId"> & { export type MessageWithMember = Omit<Message, "channelId"> & {
@ -30,12 +31,6 @@ export type MessageWithMember = Omit<Message, "channelId"> & {
interface ChatItemProps { interface ChatItemProps {
type: "chat" | "dm"; type: "chat" | "dm";
message: MessageWithMember; message: MessageWithMember;
userFriendships: Array<{
senderId: string;
receiverId: string;
status: string;
}>;
currentUserId: string;
onDeleteSuccess?: (id: string) => void; onDeleteSuccess?: (id: string) => void;
onEditSuccess?: (id: string, newContent: string) => void; onEditSuccess?: (id: string, newContent: string) => void;
} }
@ -44,13 +39,14 @@ interface ChatItemProps {
export function ChatItem({ export function ChatItem({
type, type,
message, message,
currentUserId,
userFriendships,
onDeleteSuccess, onDeleteSuccess,
onEditSuccess, onEditSuccess,
}: ChatItemProps) { }: ChatItemProps) {
const router = useRouter(); const router = useRouter();
const { setActiveServer } = useActiveServer(); const { setActiveServer } = useActiveServer();
const { currentUser, friendships } = useUser();
const currentUserId = currentUser.id;
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@ -113,15 +109,11 @@ export function ChatItem({
/** Determines the friendship status between the current user and the message author. */ /** Determines the friendship status between the current user and the message author. */
const getFriendshipStatus = () => { const getFriendshipStatus = () => {
if ( if (!user?.id || user.id === currentUserId || !Array.isArray(friendships)) {
!user?.id ||
user.id === currentUserId ||
!Array.isArray(userFriendships)
) {
return null; return null;
} }
const friendship = userFriendships.find( const friendship = friendships.find(
(f) => (f) =>
(f.senderId === user.id && f.receiverId === currentUserId) || (f.senderId === user.id && f.receiverId === currentUserId) ||
(f.receiverId === user.id && f.senderId === currentUserId), (f.receiverId === user.id && f.senderId === currentUserId),

View file

@ -13,12 +13,6 @@ export interface ChatMessagesProps {
type: "chat" | "dm"; type: "chat" | "dm";
name: string; name: string;
initialMessages: MessageWithMember[]; initialMessages: MessageWithMember[];
userFriendships: Array<{
senderId: string;
receiverId: string;
status: string;
}>;
currentUserId: string;
onDeleteMessage?: (id: string) => void; onDeleteMessage?: (id: string) => void;
onEditMessage?: (id: string, newContent: string) => void; onEditMessage?: (id: string, newContent: string) => void;
} }
@ -55,8 +49,6 @@ export function ChatMessages({
type, type,
name, name,
initialMessages, initialMessages,
currentUserId,
userFriendships,
onDeleteMessage, onDeleteMessage,
onEditMessage, onEditMessage,
}: ChatMessagesProps) { }: ChatMessagesProps) {
@ -156,8 +148,6 @@ export function ChatMessages({
<ChatItem <ChatItem
type={type} type={type}
message={message} message={message}
currentUserId={currentUserId}
userFriendships={userFriendships}
onDeleteSuccess={handleDeleteMessage} onDeleteSuccess={handleDeleteMessage}
onEditSuccess={handleEditMessage} onEditSuccess={handleEditMessage}
/> />

View file

@ -19,16 +19,6 @@ interface MemberHeaderProps {
onClose: () => void; onClose: () => void;
} }
/** Properties for the MemberSidebar component. */
interface MemberSidebarProps {
currentUserId: string;
userFriendships: Array<{
senderId: string;
receiverId: string;
status: string;
}>;
}
/** Renders the header section of the member sidebar with a title and close button. */ /** Renders the header section of the member sidebar with a title and close button. */
function MemberHeader({ title, onClose }: MemberHeaderProps) { function MemberHeader({ title, onClose }: MemberHeaderProps) {
return ( return (
@ -49,10 +39,7 @@ function MemberHeader({ title, onClose }: MemberHeaderProps) {
} }
/** Displays the list of members for the active server in a responsive sidebar or drawer layout. */ /** Displays the list of members for the active server in a responsive sidebar or drawer layout. */
export function MemberSidebar({ export function MemberSidebar() {
currentUserId,
userFriendships = [],
}: MemberSidebarProps) {
const { isMembersOpen, closeMembers } = useSidebarStore(); const { isMembersOpen, closeMembers } = useSidebarStore();
const { activeServer } = useActiveServer(); const { activeServer } = useActiveServer();
const desktopSidebarRef = useRef<HTMLElement>(null); const desktopSidebarRef = useRef<HTMLElement>(null);
@ -124,13 +111,7 @@ export function MemberSidebar({
</div> </div>
); );
} }
return ( return <MemberList members={members} />;
<MemberList
members={members}
currentUserId={currentUserId}
userFriendships={userFriendships}
/>
);
}; };
return ( return (

View file

@ -13,29 +13,22 @@ import { UserAvatar } from "../ui/UserAvatar";
import { UserProfilePopover } from "../ui/UserProfilePopover"; import { UserProfilePopover } from "../ui/UserProfilePopover";
import { useActiveServer } from "@/lib/context/ServerContext"; import { useActiveServer } from "@/lib/context/ServerContext";
import { useSidebarStore } from "@/lib/stores/useSidebarStore"; import { useSidebarStore } from "@/lib/stores/useSidebarStore";
import { useUser } from "@/lib/context/UserContext";
/** Props for the MemberItem component. */ /** Props for the MemberItem component. */
interface MemberItemProps { interface MemberItemProps {
member: User; member: User;
currentUserId: string;
userFriendships: Array<{
senderId: string;
receiverId: string;
status: string;
}>;
isOffline?: boolean; isOffline?: boolean;
} }
/** Renders an individual member item with an interactive user profile popover. */ /** Renders an individual member item with an interactive user profile popover. */
export function MemberItem({ export function MemberItem({ member, isOffline = false }: MemberItemProps) {
member,
currentUserId,
userFriendships = [],
isOffline = false,
}: MemberItemProps) {
const router = useRouter(); const router = useRouter();
const { setActiveServer } = useActiveServer(); const { setActiveServer } = useActiveServer();
const { closeMembers } = useSidebarStore(); const { closeMembers } = useSidebarStore();
const { currentUser, friendships } = useUser();
const currentUserId = currentUser.id;
const [isProfileOpen, setIsProfileOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -71,11 +64,11 @@ export function MemberItem({
/** Determines the friendship status between the logged-in user and this member. */ /** Determines the friendship status between the logged-in user and this member. */
const getFriendshipStatus = () => { const getFriendshipStatus = () => {
if (!member.id || member.id === currentUserId || !userFriendships) { if (!member.id || member.id === currentUserId || !friendships) {
return null; return null;
} }
const friendship = userFriendships.find( const friendship = friendships.find(
(f) => (f) =>
(f.senderId === member.id && f.receiverId === currentUserId) || (f.senderId === member.id && f.receiverId === currentUserId) ||
(f.receiverId === member.id && f.senderId === currentUserId), (f.receiverId === member.id && f.senderId === currentUserId),

View file

@ -1,29 +1,18 @@
/** /**
* @file components/members/MemberList.tsx * @file components/members/MemberList.tsx
* @description Renders categorized lists of online and offline members with their friendship statuses. * @description Renders categorized lists of online and offline members using the UserContext.
*/ */
import { useMemo } from "react"; import { useMemo } from "react";
import { MemberItem } from "@/components/members/MemberItem"; import { MemberItem } from "@/components/members/MemberItem";
import { User } from "@/db/schema"; import { User } from "@/db/schema";
/** Props for the MemberList component. */
interface MemberListProps { interface MemberListProps {
members: User[]; members: User[];
currentUserId: string;
userFriendships: Array<{
senderId: string;
receiverId: string;
status: string;
}>;
} }
/** Renders online and offline community members in distinct sections. */ /** Renders online and offline community members in distinct sections. */
export function MemberList({ export function MemberList({ members = [] }: MemberListProps) {
members = [],
currentUserId,
userFriendships = [],
}: MemberListProps) {
const { onlineMembers, offlineMembers } = useMemo(() => { const { onlineMembers, offlineMembers } = useMemo(() => {
return { return {
onlineMembers: members.filter((m) => m.status !== "OFFLINE"), onlineMembers: members.filter((m) => m.status !== "OFFLINE"),
@ -40,12 +29,7 @@ export function MemberList({
</h2> </h2>
<div className="space-y-0.5"> <div className="space-y-0.5">
{onlineMembers.map((member) => ( {onlineMembers.map((member) => (
<MemberItem <MemberItem key={member.id} member={member} />
key={member.id}
member={member}
currentUserId={currentUserId}
userFriendships={userFriendships}
/>
))} ))}
{onlineMembers.length === 0 && ( {onlineMembers.length === 0 && (
<p className="text-xs text-muted/60 px-2 italic"> <p className="text-xs text-muted/60 px-2 italic">
@ -62,13 +46,7 @@ export function MemberList({
</h2> </h2>
<div className="space-y-0.5"> <div className="space-y-0.5">
{offlineMembers.map((member) => ( {offlineMembers.map((member) => (
<MemberItem <MemberItem key={member.id} member={member} isOffline />
key={member.id}
member={member}
currentUserId={currentUserId}
userFriendships={userFriendships}
isOffline
/>
))} ))}
</div> </div>
</div> </div>

View file

@ -0,0 +1,50 @@
/**
* @file lib/context/UserContext.tsx
* @description Context provider and hook for managing current user data and friendship states.
*/
"use client";
import { createContext, useContext } from "react";
import type { User } from "@/db/schema";
/** Represents a friendship record between two users. */
interface Friendship {
senderId: string;
receiverId: string;
status: string;
}
/** Shape of the user context value. */
interface UserContextType {
currentUser: User;
friendships: Friendship[];
}
const UserContext = createContext<UserContextType | null>(null);
/** Provides user data and friendship states to child components. */
export function UserProvider({
children,
currentUser,
friendships,
}: {
children: React.ReactNode;
currentUser: User;
friendships: Friendship[];
}) {
return (
<UserContext.Provider value={{ currentUser, friendships }}>
{children}
</UserContext.Provider>
);
}
/** Custom hook to consume user context data. */
export function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error("useUser must be used within a UserProvider");
}
return context;
}