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 { 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";
|
||||||
|
|
||||||
/**
|
/** 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.
|
|
||||||
*
|
|
||||||
* @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.
|
|
||||||
*/
|
|
||||||
export default async function DirectMessagePage({
|
export default async function DirectMessagePage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -42,7 +35,7 @@ export default async function DirectMessagePage({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Database Queries
|
// 3. Database Queries
|
||||||
const [conversation, rawMessages] = await Promise.all([
|
const [conversation, rawMessages, friendships] = await Promise.all([
|
||||||
db.query.conversations.findFirst({
|
db.query.conversations.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(conversations.id, conversationId),
|
eq(conversations.id, conversationId),
|
||||||
|
|
@ -63,6 +56,7 @@ 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) {
|
||||||
|
|
@ -96,6 +90,7 @@ export default async function DirectMessagePage({
|
||||||
name={partner.username}
|
name={partner.username}
|
||||||
initialMessages={initialMessages}
|
initialMessages={initialMessages}
|
||||||
currentUserId={session.user.id}
|
currentUserId={session.user.id}
|
||||||
|
userFriendships={friendships}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* @file app/(app)/servers/[serverId]/channels/[channelId]/page.tsx
|
* @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";
|
import { auth } from "@/auth";
|
||||||
|
|
@ -12,16 +12,9 @@ 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";
|
||||||
|
|
||||||
/**
|
/** 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, 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.
|
|
||||||
*/
|
|
||||||
export default async function ChannelPage({
|
export default async function ChannelPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -33,7 +26,6 @@ export default async function ChannelPage({
|
||||||
if (!isValidUuid(serverId) || !isValidUuid(channelId)) {
|
if (!isValidUuid(serverId) || !isValidUuid(channelId)) {
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Auth Guard
|
// 2. Auth Guard
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
|
|
@ -41,15 +33,17 @@ export default async function ChannelPage({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Parallel Loading of Data
|
// 3. Parallel Loading of Data
|
||||||
const [server, channel, channelMessages] = await Promise.all([
|
const [server, channel, channelMessages, friendships] = 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) {
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex p-4 flex-col h-full bg-background min-h-0 overflow-hidden">
|
<div className="flex p-4 flex-col h-full bg-background min-h-0 overflow-hidden">
|
||||||
<AppHeader
|
<AppHeader
|
||||||
|
|
@ -62,7 +56,8 @@ export default async function ChannelPage({
|
||||||
type="chat"
|
type="chat"
|
||||||
name={channel.name}
|
name={channel.name}
|
||||||
initialMessages={channelMessages}
|
initialMessages={channelMessages}
|
||||||
currentUserId={session?.user?.id}
|
currentUserId={session.user.id}
|
||||||
|
userFriendships={friendships}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* @file components/chat/ChatItem.tsx
|
* @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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Message, User } from "@/db/schema";
|
import type { Message, User } from "@/db/schema";
|
||||||
import { UserAvatar } from "../ui/UserAvatar";
|
import { UserAvatar } from "../ui/UserAvatar";
|
||||||
import { ChatItemActions } from "./ChatItemActions";
|
import { ChatItemActions } from "./ChatItemActions";
|
||||||
import { ChatItemEdit } from "./ChatItemEdit";
|
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. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export type MessageWithMember = Omit<Message, "channelId"> & {
|
export type MessageWithMember = Omit<Message, "channelId"> & {
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
|
|
@ -36,48 +26,41 @@ export type MessageWithMember = Omit<Message, "channelId"> & {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Properties for the ChatItem component. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
interface ChatItemProps {
|
interface ChatItemProps {
|
||||||
type: "chat" | "dm";
|
type: "chat" | "dm";
|
||||||
message: MessageWithMember;
|
message: MessageWithMember;
|
||||||
|
userFriendships: Array<{
|
||||||
|
senderId: string;
|
||||||
|
receiverId: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
onDeleteSuccess?: (id: string) => void;
|
onDeleteSuccess?: (id: string) => void;
|
||||||
onEditSuccess?: (id: string, newContent: string) => void;
|
onEditSuccess?: (id: string, newContent: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Renders an individual chat message row with support for user profiles, editing, and deletion. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function ChatItem({
|
export function ChatItem({
|
||||||
type,
|
type,
|
||||||
message,
|
message,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
userFriendships,
|
||||||
onDeleteSuccess,
|
onDeleteSuccess,
|
||||||
onEditSuccess,
|
onEditSuccess,
|
||||||
}: ChatItemProps) {
|
}: ChatItemProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { setActiveServer } = useActiveServer();
|
||||||
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||||
const [content, setContent] = useState(message.content);
|
const [content, setContent] = useState(message.content);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const profileRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nameBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const isDirect = type;
|
const isDirect = type;
|
||||||
const user = message.member.user;
|
const user = message.member.user;
|
||||||
const fullName = user.username.trim();
|
const fullName = user.username.trim();
|
||||||
|
|
@ -95,19 +78,89 @@ export function ChatItem({
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine the dynamic endpoint based on the chat type
|
|
||||||
const apiEndpoint =
|
const apiEndpoint =
|
||||||
isDirect === "dm"
|
isDirect === "dm"
|
||||||
? `/api/dm/messages/${message.id}`
|
? `/api/dm/messages/${message.id}`
|
||||||
: `/api/messages/${message.id}`;
|
: `/api/messages/${message.id}`;
|
||||||
|
|
||||||
/**
|
useEffect(() => {
|
||||||
* Handles the asynchronous deletion of the chat message.
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
*
|
if (
|
||||||
* @async
|
profileRef.current &&
|
||||||
* @function handleDelete
|
!profileRef.current.contains(event.target as Node)
|
||||||
* @returns {Promise<void>} Resolves when the delete operation completes or fails.
|
) {
|
||||||
*/
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (isDeleting) return;
|
if (isDeleting) return;
|
||||||
|
|
||||||
|
|
@ -131,13 +184,7 @@ export function ChatItem({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Updates the message content via API PATCH request. */
|
||||||
* Handles the asynchronous update/editing of the chat message content.
|
|
||||||
*
|
|
||||||
* @async
|
|
||||||
* @function handleEdit
|
|
||||||
* @returns {Promise<void>} Resolves when the edit operation completes or fails.
|
|
||||||
*/
|
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
if (!content.trim() || isLoading) return;
|
if (!content.trim() || isLoading) return;
|
||||||
|
|
||||||
|
|
@ -166,15 +213,41 @@ export function ChatItem({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<UserAvatar user={user} size="md" />
|
<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-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-baseline gap-2 min-w-0">
|
<div
|
||||||
<span className="font-semibold text-white text-sm hover:underline cursor-pointer truncate">
|
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}
|
{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>
|
<span className="text-xs text-muted shrink-0">{formattedTime}</span>
|
||||||
{isUpdated && (
|
{isUpdated && (
|
||||||
<span className="text-[10px] text-muted shrink-0">(edited)</span>
|
<span className="text-[10px] text-muted shrink-0">(edited)</span>
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,22 @@
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { ChatItem, type MessageWithMember } from "./ChatItem";
|
import { ChatItem, type MessageWithMember } from "./ChatItem";
|
||||||
|
|
||||||
/**
|
/** Properties for the ChatMessages component. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export interface ChatMessagesProps {
|
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;
|
currentUserId: string;
|
||||||
onDeleteMessage?: (id: string) => void;
|
onDeleteMessage?: (id: string) => void;
|
||||||
onEditMessage?: (id: string, newContent: string) => void;
|
onEditMessage?: (id: string, newContent: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Formats a date string or Date object into a readable label (Today, Yesterday, or formatted date). */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
function formatDateLabel(dateString: string | Date): string {
|
function formatDateLabel(dateString: string | Date): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const now = new Date();
|
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. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function ChatMessages({
|
export function ChatMessages({
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
userFriendships,
|
||||||
onDeleteMessage,
|
onDeleteMessage,
|
||||||
onEditMessage,
|
onEditMessage,
|
||||||
}: ChatMessagesProps) {
|
}: ChatMessagesProps) {
|
||||||
|
|
@ -85,7 +64,6 @@ export function ChatMessages({
|
||||||
useState<MessageWithMember[]>(initialMessages);
|
useState<MessageWithMember[]>(initialMessages);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Synchronisiere den State, wenn der Server neue initialMessages liefert (z.B. nach router.refresh())
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessages(initialMessages);
|
setMessages(initialMessages);
|
||||||
}, [initialMessages]);
|
}, [initialMessages]);
|
||||||
|
|
@ -179,6 +157,7 @@ export function ChatMessages({
|
||||||
type={type}
|
type={type}
|
||||||
message={message}
|
message={message}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
userFriendships={userFriendships}
|
||||||
onDeleteSuccess={handleDeleteMessage}
|
onDeleteSuccess={handleDeleteMessage}
|
||||||
onEditSuccess={handleEditMessage}
|
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