feat(chat): integrate UserProfilePopover with direct messaging and friend requests
This commit is contained in:
parent
c181ec1848
commit
98a982b9fb
6 changed files with 333 additions and 110 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<UserAvatar user={user} size="md" />
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
148
components/ui/UserProfilePopover.tsx
Normal file
148
components/ui/UserProfilePopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
lib/services/friends.service.ts
Normal file
33
lib/services/friends.service.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue