feat(chat): integrate UserProfilePopover with direct messaging and friend requests

This commit is contained in:
Chneemann 2026-09-07 13:46:22 +02:00
parent c181ec1848
commit 98a982b9fb
No known key found for this signature in database
6 changed files with 333 additions and 110 deletions

View file

@ -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<JSX.Element>} 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}
/>
<ChatInput

View file

@ -1,6 +1,6 @@
/**
* @file app/(app)/servers/[serverId]/channels/[channelId]/page.tsx
* @description Dynamic server channel page component performing authentication, parameter validation, parallel data fetching, and rendering the chat layout.
* @description Server channel chat page component handling authentication guards, parallel data fetching for server and messages, and rendering the channel layout.
*/
import { auth } from "@/auth";
@ -12,16 +12,9 @@ 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";
/**
* Renders the channel chat view by validating parameters, checking user session, fetching channel data, and displaying headers, messages, and input controls.
*
* @async
* @function ChannelPage
* @param {Object} props - The component props containing route parameters.
* @param {Promise<{ serverId: string; channelId: string }>} props.params - Route parameters containing server and channel identifiers.
* @returns {Promise<JSX.Element>} 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 (
<div className="flex p-4 flex-col h-full bg-background min-h-0 overflow-hidden">
<AppHeader
@ -62,7 +56,8 @@ export default async function ChannelPage({
type="chat"
name={channel.name}
initialMessages={channelMessages}
currentUserId={session?.user?.id}
currentUserId={session.user.id}
userFriendships={friendships}
/>
<ChatInput

View file

@ -1,30 +1,20 @@
/**
* @file components/chat/ChatItem.tsx
* @description Single message row component supporting editing, deletion, avatar rendering, and user role tracking for chat channels and direct messages.
* @description Single message row component supporting editing, deletion, avatar rendering, and user profile popover with quick action triggers.
*/
"use client";
import { useState } from "react";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Message, User } from "@/db/schema";
import { UserAvatar } from "../ui/UserAvatar";
import { ChatItemActions } from "./ChatItemActions";
import { ChatItemEdit } from "./ChatItemEdit";
import { UserProfilePopover } from "../ui/UserProfilePopover";
import { useActiveServer } from "@/lib/context/ServerContext";
/**
* Composite message type extending base database Message with channel/conversation details and member relation.
*
* @interface MessageWithMember
* @property {string} id - The unique identifier of the message.
* @property {string} content - The text content of the message.
* @property {string | Date} createdAt - The creation timestamp of the message.
* @property {string | Date} [updatedAt] - The optional update timestamp of the message.
* @property {string} [channelId] - Optional associated channel identifier.
* @property {string} [conversationId] - Optional associated conversation identifier.
* @property {"chat" | "dm"} [type] - Optional chat type indicator.
* @property {{ id: string; role: string; user: User }} member - Associated member details including user relation.
*/
/** Composite message type extending base database Message with channel/conversation details and member relation. */
export type MessageWithMember = Omit<Message, "channelId"> & {
channelId?: string;
conversationId?: string;
@ -36,48 +26,41 @@ export type MessageWithMember = Omit<Message, "channelId"> & {
};
};
/**
* 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<HTMLDivElement>(null);
const nameBtnRef = useRef<HTMLButtonElement>(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<void>} 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<void>} 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 (
<div className="flex items-start gap-3 group p-2 rounded-xl hover:bg-surface transition-colors">
<div className="flex items-start gap-3 group p-2 rounded-xl hover:bg-surface transition-colors relative">
<button
type="button"
onClick={() => setIsProfileOpen((prev) => !prev)}
className="focus:outline-none cursor-pointer shrink-0"
>
<UserAvatar user={user} size="md" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-baseline gap-2 min-w-0">
<span className="font-semibold text-white text-sm hover:underline cursor-pointer truncate">
<div
className="flex items-baseline gap-2 min-w-0 relative"
ref={profileRef}
>
<button
ref={nameBtnRef}
type="button"
onClick={() => setIsProfileOpen((prev) => !prev)}
className="font-semibold text-white text-sm hover:underline cursor-pointer truncate focus:outline-none text-left"
>
{fullName}
</span>
</button>
{isProfileOpen && (
<UserProfilePopover
user={user}
currentUserId={currentUserId}
triggerRef={nameBtnRef}
friendshipStatus={friendshipStatus}
onDirectMessage={handleStartConversation}
onAddFriend={handleAddFriend}
/>
)}
<span className="text-xs text-muted shrink-0">{formattedTime}</span>
{isUpdated && (
<span className="text-[10px] text-muted shrink-0">(edited)</span>

View file

@ -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<MessageWithMember[]>(initialMessages);
const scrollRef = useRef<HTMLDivElement>(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}
/>

View file

@ -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<HTMLButtonElement | HTMLDivElement | null>;
friendshipStatus?: string | null;
onDirectMessage?: (userId: string) => void;
onAddFriend?: (username: string) => Promise<void> | void;
}
/** Popover component for displaying user info and quick actions. */
export function UserProfilePopover({
user,
currentUserId,
triggerRef,
friendshipStatus,
onDirectMessage,
onAddFriend,
}: UserProfilePopoverProps) {
const popoverRef = useRef<HTMLDivElement>(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 (
<div
ref={popoverRef}
style={{
position: "fixed",
top: `${coords.top}px`,
left: `${coords.left}px`,
opacity: isVisible ? 1 : 0,
}}
className="z-50 w-64 bg-background border border-surface/80 rounded-2xl p-4 shadow-2xl transition-opacity duration-75 pointer-events-auto"
>
<div className="flex items-center gap-3 mb-3">
<UserAvatar user={user} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate leading-tight">
{user.username}
</p>
<p className="text-xs text-muted truncate leading-tight font-medium capitalize">
{user.status.toLowerCase()}
</p>
</div>
</div>
{!isSelf && (
<div className="border-t border-surface/60 pt-3 mt-3 flex items-center gap-2">
<ActionButton
type="button"
variant="primary"
onClick={() => onDirectMessage?.(user.id)}
icon={MessageSquare}
size="sm"
className="flex-1"
>
Message
</ActionButton>
<button
type="button"
disabled={isButtonDisabled}
onClick={handleAddFriendClick}
className={`flex items-center justify-center p-1.5 text-xs rounded-lg border transition-colors ${
isButtonDisabled
? "bg-surface text-muted/50 border-transparent cursor-not-allowed"
: "bg-surface hover:bg-surface/80 text-foreground hover:text-muted border-surface/80 cursor-pointer"
}`}
title={
isAlreadyFriend
? "Already Friends"
: isPending
? "Request Pending"
: "Add Friend"
}
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-muted" />
) : isAlreadyFriend ? (
<Check className="w-3.5 h-3.5 text-accent" />
) : isPending ? (
<Clock className="w-3.5 h-3.5 text-amber-500" />
) : (
<UserPlus className="w-3.5 h-3.5" />
)}
</button>
</div>
)}
</div>
);
}

View file

@ -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 [];
}
}