From 98a982b9fbd04e2d25bb22b151784a7966a162b8 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Mon, 7 Sep 2026 13:46:22 +0200 Subject: [PATCH] feat(chat): integrate UserProfilePopover with direct messaging and friend requests --- app/(app)/dm/[conversationId]/page.tsx | 15 +- .../[serverId]/channels/[channelId]/page.tsx | 21 +- components/chat/ChatItem.tsx | 185 ++++++++++++------ components/chat/ChatMessages.tsx | 41 +--- components/ui/UserProfilePopover.tsx | 148 ++++++++++++++ lib/services/friends.service.ts | 33 ++++ 6 files changed, 333 insertions(+), 110 deletions(-) create mode 100644 components/ui/UserProfilePopover.tsx create mode 100644 lib/services/friends.service.ts diff --git a/app/(app)/dm/[conversationId]/page.tsx b/app/(app)/dm/[conversationId]/page.tsx index 59f36bb..d7d2cfd 100644 --- a/app/(app)/dm/[conversationId]/page.tsx +++ b/app/(app)/dm/[conversationId]/page.tsx @@ -13,16 +13,9 @@ 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"; -/** - * Renders the direct message conversation page with header, message history, and input field. - * - * @async - * @function DirectMessagePage - * @param {Object} props - The page props containing parameters. - * @param {Promise<{ conversationId: string }>} props.params - Route parameters containing the conversation identifier. - * @returns {Promise} The rendered direct message page layout. - */ +/** Renders the direct message conversation page with header, message history, and input field. */ export default async function DirectMessagePage({ params, }: { @@ -42,7 +35,7 @@ export default async function DirectMessagePage({ } // 3. Database Queries - const [conversation, rawMessages] = await Promise.all([ + const [conversation, rawMessages, friendships] = await Promise.all([ db.query.conversations.findFirst({ where: and( eq(conversations.id, conversationId), @@ -63,6 +56,7 @@ export default async function DirectMessagePage({ }, orderBy: (dm, { asc }) => [asc(dm.createdAt)], }), + getUserFriendships(session.user.id), ]); if (!conversation) { @@ -96,6 +90,7 @@ export default async function DirectMessagePage({ name={partner.username} initialMessages={initialMessages} currentUserId={session.user.id} + userFriendships={friendships} /> } props.params - Route parameters containing server and channel identifiers. - * @returns {Promise} The rendered channel page layout. - */ +/** Renders the channel chat view by validating parameters, checking user session, fetching channel data, friendships, and displaying headers, messages, and input controls. */ export default async function ChannelPage({ params, }: { @@ -33,7 +26,6 @@ export default async function ChannelPage({ if (!isValidUuid(serverId) || !isValidUuid(channelId)) { redirect("/"); } - // 2. Auth Guard const session = await auth(); if (!session?.user?.id) { @@ -41,15 +33,17 @@ export default async function ChannelPage({ } // 3. Parallel Loading of Data - const [server, channel, channelMessages] = await Promise.all([ + const [server, channel, channelMessages, friendships] = await Promise.all([ getServerById(serverId), getChannelById(channelId), getChannelMessages(channelId), + getUserFriendships(session.user.id), ]); if (!channel || !server) { redirect("/"); } + return (
& { channelId?: string; conversationId?: string; @@ -36,48 +26,41 @@ export type MessageWithMember = Omit & { }; }; -/** - * Properties for the ChatItem component. - * - * @interface ChatItemProps - * @property {"chat" | "dm"} type - The type of chat context (channel chat or direct message). - * @property {MessageWithMember} message - The message object containing member and content data. - * @property {string} currentUserId - The unique identifier of the currently logged-in user. - * @property {(id: string) => void} [onDeleteSuccess] - Optional callback executed when a message is successfully deleted. - * @property {(id: string, newContent: string) => void} [onEditSuccess] - Optional callback executed when a message is successfully edited. - */ +/** Properties for the ChatItem component. */ 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; } -/** - * Renders an individual chat message row with support for inline editing, deletion, and status indicators. - * - * @param {ChatItemProps} props - The component props. - * @param {"chat" | "dm"} props.type - The type of chat context. - * @param {MessageWithMember} props.message - The message object. - * @param {string} props.currentUserId - The unique identifier of the current user. - * @param {(id: string) => void} [props.onDeleteSuccess] - Callback on successful deletion. - * @param {(id: string, newContent: string) => void} [props.onEditSuccess] - Callback on successful edit. - * @returns {JSX.Element} The rendered chat item component. - */ +/** Renders an individual chat message row with support for user profiles, editing, and deletion. */ export function ChatItem({ type, message, currentUserId, + userFriendships, onDeleteSuccess, onEditSuccess, }: ChatItemProps) { const router = useRouter(); + const { setActiveServer } = useActiveServer(); + const [isDeleting, setIsDeleting] = useState(false); const [isEditing, setIsEditing] = useState(false); + const [isProfileOpen, setIsProfileOpen] = useState(false); const [content, setContent] = useState(message.content); const [isLoading, setIsLoading] = useState(false); + const profileRef = useRef(null); + const nameBtnRef = useRef(null); + const isDirect = type; const user = message.member.user; const fullName = user.username.trim(); @@ -95,19 +78,89 @@ export function ChatItem({ }, ); - // Determine the dynamic endpoint based on the chat type const apiEndpoint = isDirect === "dm" ? `/api/dm/messages/${message.id}` : `/api/messages/${message.id}`; - /** - * Handles the asynchronous deletion of the chat message. - * - * @async - * @function handleDelete - * @returns {Promise} Resolves when the delete operation completes or fails. - */ + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + profileRef.current && + !profileRef.current.contains(event.target as Node) + ) { + setIsProfileOpen(false); + } + }; + + if (isProfileOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isProfileOpen]); + + const getFriendshipStatus = () => { + if ( + !user?.id || + user.id === currentUserId || + !Array.isArray(userFriendships) + ) { + return null; + } + + const friendship = userFriendships.find( + (f) => + (f.senderId === user.id && f.receiverId === currentUserId) || + (f.receiverId === user.id && f.senderId === currentUserId), + ); + + return friendship ? friendship.status : null; + }; + + const friendshipStatus = getFriendshipStatus(); + + /** Starts a direct message conversation with the specified recipient. */ + const handleStartConversation = async (recipientId: string) => { + try { + const res = await fetch("/api/dm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recipientId }), + }); + + if (res.ok) { + const conversation = await res.json(); + setActiveServer(null); + router.push(`/dm/${conversation.id}`); + } + } catch (error) { + console.error("Failed to start conversation:", error); + } + }; + + /** Sends a friend request to the user with the specified username. */ + const handleAddFriend = async (username: string) => { + try { + const res = await fetch("/api/friends", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + + if (res.ok) { + router.refresh(); + } else { + const data = await res.json(); + console.error("Failed to send friend request:", data.error); + } + } catch (error) { + console.error("Error sending friend request:", error); + } + }; + + /** Deletes the current chat message via API call. */ const handleDelete = async () => { if (isDeleting) return; @@ -131,13 +184,7 @@ export function ChatItem({ } }; - /** - * Handles the asynchronous update/editing of the chat message content. - * - * @async - * @function handleEdit - * @returns {Promise} Resolves when the edit operation completes or fails. - */ + /** Updates the message content via API PATCH request. */ const handleEdit = async () => { if (!content.trim() || isLoading) return; @@ -166,15 +213,41 @@ export function ChatItem({ }; return ( -
- +
+
-
- +
+ + + {isProfileOpen && ( + + )} + {formattedTime} {isUpdated && ( (edited) diff --git a/components/chat/ChatMessages.tsx b/components/chat/ChatMessages.tsx index f72d79c..490e5f6 100644 --- a/components/chat/ChatMessages.tsx +++ b/components/chat/ChatMessages.tsx @@ -8,33 +8,22 @@ import { useState, useEffect, useRef } from "react"; import { ChatItem, type MessageWithMember } from "./ChatItem"; -/** - * Properties for the ChatMessages component. - * - * @interface ChatMessagesProps - * @property {"chat" | "dm"} type - The context type of the chat, either channel chat or direct message. - * @property {string} name - The name of the channel or the direct message recipient. - * @property {MessageWithMember[]} initialMessages - Initial array of messages loaded for the view. - * @property {string} [currentUserId] - The unique identifier of the currently logged-in user. - * @property {(id: string) => void} [onDeleteMessage] - Optional callback function triggered when a message is deleted. - * @property {(id: string, newContent: string) => void} [onEditMessage] - Optional callback function triggered when a message is edited. - */ +/** Properties for the ChatMessages component. */ 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; } -/** - * Formats a date string or Date object into a readable label (Today, Yesterday, or formatted date). - * - * @function formatDateLabel - * @param {string | Date} dateString - The date string or object to format. - * @returns {string} The formatted date label. - */ +/** Formats a date string or Date object into a readable label (Today, Yesterday, or formatted date). */ function formatDateLabel(dateString: string | Date): string { const date = new Date(dateString); const now = new Date(); @@ -61,23 +50,13 @@ function formatDateLabel(dateString: string | Date): string { }); } -/** - * Renders the scrollable message feed with greetings, date dividers, and interactive chat items. - * - * @param {ChatMessagesProps} props - The component props. - * @param {"chat" | "dm"} props.type - The context type of the chat. - * @param {string} props.name - The name of the channel or user. - * @param {MessageWithMember[]} props.initialMessages - Initial array of messages. - * @param {string} [props.currentUserId] - The unique identifier of the current user. - * @param {(id: string) => void} [props.onDeleteMessage] - Optional message deletion callback. - * @param {(id: string, newContent: string) => void} [props.onEditMessage] - Optional message editing callback. - * @returns {JSX.Element} The rendered chat messages container. - */ +/** Renders the scrollable message feed with greetings, date dividers, and interactive chat items. */ export function ChatMessages({ type, name, initialMessages, currentUserId, + userFriendships, onDeleteMessage, onEditMessage, }: ChatMessagesProps) { @@ -85,7 +64,6 @@ export function ChatMessages({ useState(initialMessages); const scrollRef = useRef(null); - // Synchronisiere den State, wenn der Server neue initialMessages liefert (z.B. nach router.refresh()) useEffect(() => { setMessages(initialMessages); }, [initialMessages]); @@ -179,6 +157,7 @@ export function ChatMessages({ type={type} message={message} currentUserId={currentUserId} + userFriendships={userFriendships} onDeleteSuccess={handleDeleteMessage} onEditSuccess={handleEditMessage} /> diff --git a/components/ui/UserProfilePopover.tsx b/components/ui/UserProfilePopover.tsx new file mode 100644 index 0000000..829ed65 --- /dev/null +++ b/components/ui/UserProfilePopover.tsx @@ -0,0 +1,148 @@ +/** + * @file components/ui/UserProfilePopover.tsx + * @description User profile popover component displaying user details, email, and interactive options to message or add them as a friend. + */ + +"use client"; + +import { useLayoutEffect, useRef, useState } from "react"; +import { MessageSquare, UserPlus, Check, Clock, Loader2 } from "lucide-react"; +import type { User } from "@/db/schema"; +import { UserAvatar } from "../ui/UserAvatar"; +import { ActionButton } from "./ActionButton"; + +/** Props for the UserProfilePopover component. */ +interface UserProfilePopoverProps { + user: User; + currentUserId: string; + triggerRef: React.RefObject; + friendshipStatus?: string | null; + onDirectMessage?: (userId: string) => void; + onAddFriend?: (username: string) => Promise | void; +} + +/** Popover component for displaying user info and quick actions. */ +export function UserProfilePopover({ + user, + currentUserId, + triggerRef, + friendshipStatus, + onDirectMessage, + onAddFriend, +}: UserProfilePopoverProps) { + const popoverRef = useRef(null); + const [coords, setCoords] = useState<{ top: number; left: number }>({ + top: 0, + left: 0, + }); + const [isVisible, setIsVisible] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [justSent, setJustSent] = useState(false); + + const isSelf = user.id === currentUserId; + + const isAlreadyFriend = friendshipStatus === "ACCEPTED"; + const isPending = friendshipStatus === "PENDING" || justSent; + const isButtonDisabled = isLoading || isAlreadyFriend || isPending; + + useLayoutEffect(() => { + if (!triggerRef.current || !popoverRef.current) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + const popoverRect = popoverRef.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + + let top = triggerRect.bottom + 8; + const left = triggerRect.left; + + if (top + popoverRect.height > viewportHeight - 16) { + top = triggerRect.top - popoverRect.height - 8; + } + + setCoords({ top, left }); + setIsVisible(true); + }, [triggerRef]); + + /** Handles sending a friend request asynchronously. */ + const handleAddFriendClick = async () => { + if (isButtonDisabled || !onAddFriend) return; + setIsLoading(true); + + try { + await onAddFriend(user.username); + setJustSent(true); + } catch (err) { + console.error("Failed to add friend", err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ +
+

+ {user.username} +

+

+ {user.status.toLowerCase()} +

+
+
+ + {!isSelf && ( +
+ onDirectMessage?.(user.id)} + icon={MessageSquare} + size="sm" + className="flex-1" + > + Message + + + +
+ )} +
+ ); +} diff --git a/lib/services/friends.service.ts b/lib/services/friends.service.ts new file mode 100644 index 0000000..ec9cfa2 --- /dev/null +++ b/lib/services/friends.service.ts @@ -0,0 +1,33 @@ +/** + * @file lib/services/friends.service.ts + * @description Service module providing database queries for managing and retrieving user friendships. + */ + +import { db } from "@/db"; +import { friendships } from "@/db/schema"; +import { eq, or } from "drizzle-orm"; + +/** Retrieves all friendship records where the specified user is either the sender or the receiver. */ +export async function getUserFriendships(userId: string) { + try { + const result = await db + .select({ + id: friendships.id, + senderId: friendships.senderId, + receiverId: friendships.receiverId, + status: friendships.status, + }) + .from(friendships) + .where( + or( + eq(friendships.senderId, userId), + eq(friendships.receiverId, userId), + ), + ); + + return result; + } catch (error) { + console.error("Error fetching user friendships:", error); + return []; + } +}