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

View file

@ -13,6 +13,7 @@ import { MemberSidebar } from "@/components/layout/MemberSidebar";
import { ServerProvider } from "@/lib/context/ServerContext";
import { redirect } from "next/navigation";
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. */
export default async function AppLayout({
@ -30,16 +31,7 @@ export default async function AppLayout({
// 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),
db.select().from(users).where(eq(users.id, currentUserId)).limit(1),
getUserServers(currentUserId),
db.query.conversations.findMany({
where: or(
@ -78,19 +70,18 @@ export default async function AppLayout({
]);
return (
<ServerProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<AppSidebar
servers={userServers}
conversations={formattedConversations}
user={currentUser}
/>
<div className="flex-1 flex min-w-0">{children}</div>
<MemberSidebar
currentUserId={currentUserId}
userFriendships={friendships}
/>
</div>
</ServerProvider>
<UserProvider currentUser={currentUser} friendships={friendships}>
<ServerProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<AppSidebar
servers={userServers}
conversations={formattedConversations}
user={currentUser}
/>
<div className="flex-1 flex min-w-0">{children}</div>
<MemberSidebar />
</div>
</ServerProvider>
</UserProvider>
);
}

View file

@ -12,7 +12,6 @@ import { getChannelById } from "@/lib/services/channel.service";
import { getChannelMessages } from "@/lib/services/message.service";
import { getServerById } from "@/lib/services/server.service";
import { isValidUuid } from "@/lib/utils";
import { getUserFriendships } from "@/lib/services/friends.service";
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. */
@ -34,11 +33,10 @@ export default async function ChannelPage({
}
// 3. Parallel Loading of Data
const [server, channel, channelMessages, friendships] = await Promise.all([
const [server, channel, channelMessages] = await Promise.all([
getServerById(serverId),
getChannelById(channelId),
getChannelMessages(channelId),
getUserFriendships(session.user.id),
]);
if (!channel || !server) {
@ -57,8 +55,6 @@ export default async function ChannelPage({
type="chat"
name={channel.name}
initialMessages={channelMessages}
currentUserId={session.user.id}
userFriendships={friendships}
/>
<ChatInput

View file

@ -13,6 +13,7 @@ import { ChatItemActions } from "./ChatItemActions";
import { ChatItemEdit } from "./ChatItemEdit";
import { UserProfilePopover } from "../ui/UserProfilePopover";
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. */
export type MessageWithMember = Omit<Message, "channelId"> & {
@ -30,12 +31,6 @@ export type MessageWithMember = Omit<Message, "channelId"> & {
interface ChatItemProps {
type: "chat" | "dm";
message: MessageWithMember;
userFriendships: Array<{
senderId: string;
receiverId: string;
status: string;
}>;
currentUserId: string;
onDeleteSuccess?: (id: string) => void;
onEditSuccess?: (id: string, newContent: string) => void;
}
@ -44,13 +39,14 @@ interface ChatItemProps {
export function ChatItem({
type,
message,
currentUserId,
userFriendships,
onDeleteSuccess,
onEditSuccess,
}: ChatItemProps) {
const router = useRouter();
const { setActiveServer } = useActiveServer();
const { currentUser, friendships } = useUser();
const currentUserId = currentUser.id;
const [isDeleting, setIsDeleting] = 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. */
const getFriendshipStatus = () => {
if (
!user?.id ||
user.id === currentUserId ||
!Array.isArray(userFriendships)
) {
if (!user?.id || user.id === currentUserId || !Array.isArray(friendships)) {
return null;
}
const friendship = userFriendships.find(
const friendship = friendships.find(
(f) =>
(f.senderId === user.id && f.receiverId === currentUserId) ||
(f.receiverId === user.id && f.senderId === currentUserId),

View file

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

View file

@ -19,16 +19,6 @@ interface MemberHeaderProps {
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. */
function MemberHeader({ title, onClose }: MemberHeaderProps) {
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. */
export function MemberSidebar({
currentUserId,
userFriendships = [],
}: MemberSidebarProps) {
export function MemberSidebar() {
const { isMembersOpen, closeMembers } = useSidebarStore();
const { activeServer } = useActiveServer();
const desktopSidebarRef = useRef<HTMLElement>(null);
@ -124,13 +111,7 @@ export function MemberSidebar({
</div>
);
}
return (
<MemberList
members={members}
currentUserId={currentUserId}
userFriendships={userFriendships}
/>
);
return <MemberList members={members} />;
};
return (

View file

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

View file

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