diff --git a/app/(app)/dm/[conversationId]/page.tsx b/app/(app)/dm/[conversationId]/page.tsx new file mode 100644 index 0000000..4f740e3 --- /dev/null +++ b/app/(app)/dm/[conversationId]/page.tsx @@ -0,0 +1,115 @@ +/** + * @file app/(app)/dm/[conversationId]/page.tsx + * @description Server component for direct message conversation pages, handling authentication, route validation, database fetching, and layout rendering. + */ + +import { auth } from "@/auth"; +import { db } from "@/db"; +import { conversations, directMessages } from "@/db/schema"; +import { and, eq, or } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { AppHeader } from "@/components/layout/AppHeader"; +import { ChatInput } from "@/components/chat/ChatInput"; +import { ChatMessages } from "@/components/chat/ChatMessages"; +import type { MessageWithMember } from "@/components/chat/ChatItem"; + +/** + * Regular expression to validate standard UUID v1-v5 formats. + * + * @type {RegExp} + */ +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * 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. + */ +export default async function DirectMessagePage({ + params, +}: { + params: Promise<{ conversationId: string }>; +}) { + const { conversationId } = await params; + + // 1. Validation + if (!UUID_REGEX.test(conversationId)) { + redirect("/"); + } + + // 2. Auth Guard + const session = await auth(); + if (!session?.user?.id) { + redirect("/login"); + } + + // 3. Database Queries + const [conversation, rawMessages] = await Promise.all([ + db.query.conversations.findFirst({ + where: and( + eq(conversations.id, conversationId), + or( + eq(conversations.userOneId, session.user.id), + eq(conversations.userTwoId, session.user.id), + ), + ), + with: { + userOne: true, + userTwo: true, + }, + }), + db.query.directMessages.findMany({ + where: eq(directMessages.conversationId, conversationId), + with: { + sender: true, + }, + orderBy: (dm, { asc }) => [asc(dm.createdAt)], + }), + ]); + + if (!conversation) { + redirect("/"); + } + + // 4. Identify the person to contact + const partner = + conversation.userOne.id === session.user.id + ? conversation.userTwo + : conversation.userOne; + + // 5. Format Messages for UI + const initialMessages: MessageWithMember[] = rawMessages.map((msg) => ({ + ...msg, + type: "dm", + memberId: msg.senderId, + member: { + id: msg.sender.id, + role: "MEMBER", + user: msg.sender, + }, + })); + + return ( +
+ + + + + +
+ ); +} diff --git a/app/(app)/servers/[serverId]/channels/[channelId]/page.tsx b/app/(app)/servers/[serverId]/channels/[channelId]/page.tsx index b255f5b..38b7569 100644 --- a/app/(app)/servers/[serverId]/channels/[channelId]/page.tsx +++ b/app/(app)/servers/[serverId]/channels/[channelId]/page.tsx @@ -1,6 +1,6 @@ /** - * @file app/servers/[serverId]/channels/[channelId]/page.tsx - * @description Dynamic page component for displaying a specific channel within a server, including its messages and chat input. + * @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. */ import { auth } from "@/auth"; @@ -13,51 +13,70 @@ import { getChannelMessages } from "@/lib/services/message.service"; import { getServerById } from "@/lib/services/server.service"; /** - * Renders the channel view by fetching server, channel, and message details in parallel based on route parameters. + * Regular expression for validating UUID string formats. + * + * @type {RegExp} + */ +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Renders the channel chat view by validating parameters, checking user session, fetching channel data, and displaying headers, messages, and input controls. * * @async - * @param {Object} props - The component props. - * @param {Promise<{ serverId: string; channelId: string }>} props.params - A promise resolving to the route parameters containing serverId and channelId. - * @returns {Promise} The rendered channel page interface. + * @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} The rendered channel page layout. */ export default async function ChannelPage({ params, }: { params: Promise<{ serverId: string; channelId: string }>; }) { - const session = await auth(); const { serverId, channelId } = await params; - // Parallel loading of server, channel, and messages + // 1. Check the UUID format for BOTH parameters + if (!UUID_REGEX.test(serverId) || !UUID_REGEX.test(channelId)) { + redirect("/"); + } + + // 2. Auth Guard + const session = await auth(); + if (!session?.user?.id) { + redirect("/login"); + } + + // 3. Parallel Loading of Data const [server, channel, channelMessages] = await Promise.all([ getServerById(serverId), getChannelById(channelId), getChannelMessages(channelId), ]); - if (!channel || !server) redirect("/"); - + if (!channel || !server) { + redirect("/"); + } return ( -
- {/* Header */} +
- {/* Messages Feed */} - {/* Input Field */}
); diff --git a/components/chat/ChatInput.tsx b/components/chat/ChatInput.tsx index 79c43e6..a77d9e5 100644 --- a/components/chat/ChatInput.tsx +++ b/components/chat/ChatInput.tsx @@ -1,6 +1,6 @@ /** * @file components/chat/ChatInput.tsx - * @description Client component providing an input field to create messages via REST API. + * @description Input component for sending chat messages within channels or direct message conversations, handling submission via keyboard events and API requests. */ "use client"; @@ -9,38 +9,53 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; /** - * Props for the ChatInput component. + * Base properties shared across all ChatInput variations. * - * @interface ChatInputProps - * @property {string} channelName - The name of the channel displayed in the input placeholder. - * @property {string} channelId - The ID of the channel where the message will be sent. - * @property {string} serverId - The ID of the server containing the channel. + * @interface BaseChatInputProps + * @property {string} placeholderName - The display name for the channel or recipient used in the input placeholder text. + * @property {(message: unknown) => void} [onMessageSent] - Optional callback function triggered after a message is successfully sent. */ -interface ChatInputProps { - channelName: string; - channelId: string; - serverId: string; +interface BaseChatInputProps { + placeholderName: string; + onMessageSent?: (message: unknown) => void; } /** - * Renders an input field for sending chat messages within a channel. + * Union type for ChatInput properties, supporting either a server channel or a direct message conversation context. * - * @param {ChatInputProps} props - Component properties. - * @returns {JSX.Element} The ChatInput component. + * @type {ChatInputProps} */ -export function ChatInput({ - channelName, - channelId, - serverId, -}: ChatInputProps) { +type ChatInputProps = BaseChatInputProps & + ( + | { + type: "chat"; + channelId: string; + serverId: string; + } + | { + type: "dm"; + conversationId: string; + } + ); + +/** + * Renders an input field for writing and submitting chat messages with loading states and keyboard event handlers. + * + * @param {ChatInputProps} props - The component props. + * @returns {JSX.Element} The rendered chat input component. + */ +export function ChatInput(props: ChatInputProps) { const [content, setContent] = useState(""); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); /** - * Handles key press events, submitting the message on 'Enter' (without Shift). + * Handles keydown events on the input element to submit messages when pressing Enter without Shift. * - * @param {React.KeyboardEvent} e - The keyboard event. + * @async + * @function handleKeyDown + * @param {React.KeyboardEvent} e - The keyboard event object. + * @returns {Promise} Resolves when the message submission finishes or fails. */ const handleKeyDown = async (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -51,22 +66,38 @@ export function ChatInput({ try { setIsLoading(true); - const response = await fetch("/api/messages", { + const endpoint = + props.type === "dm" + ? `/api/dm/${props.conversationId}` + : "/api/messages"; + + const payload = + props.type === "dm" + ? { content: content.trim() } + : { + content: content.trim(), + channelId: props.channelId, + serverId: props.serverId, + }; + + const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content, - channelId, - serverId, - }), + body: JSON.stringify(payload), }); if (!response.ok) { - throw new Error("Error while sending"); + throw new Error("Error sending message"); } + const data = await response.json(); setContent(""); - router.refresh(); + + if (props.onMessageSent) { + props.onMessageSent(data); + } else { + router.refresh(); + } } catch (error) { console.error("Error sending the message:", error); } finally { @@ -75,19 +106,22 @@ export function ChatInput({ } }; + const placeholderText = + props.type === "dm" + ? `Message @${props.placeholderName}` + : `Message #${props.placeholderName}`; + return ( -
-
- setContent(e.target.value)} - onKeyDown={handleKeyDown} - disabled={isLoading} - placeholder={`Message to #${channelName}`} - className="w-full bg-transparent outline-none text-foreground placeholder-muted text-sm disabled:opacity-50" - /> -
+
+ setContent(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isLoading} + placeholder={placeholderText} + className="w-full bg-transparent outline-none text-foreground placeholder-muted text-sm disabled:opacity-50" + />
); } diff --git a/components/chat/ChatItem.tsx b/components/chat/ChatItem.tsx index 7b64196..553bba9 100644 --- a/components/chat/ChatItem.tsx +++ b/components/chat/ChatItem.tsx @@ -1,29 +1,37 @@ /** * @file components/chat/ChatItem.tsx - * @description Single message row component supporting editing and deletion functionality. + * @description Single message row component supporting editing, deletion, avatar rendering, and user role tracking for chat channels and direct messages. */ "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; -import type { Message, Member, User } from "@/db/schema"; +import type { Message, User } from "@/db/schema"; import { UserAvatar } from "../ui/UserAvatar"; import { ChatItemActions } from "./ChatItemActions"; import { ChatItemEdit } from "./ChatItemEdit"; /** - * Composite message type extending base database Message with populated member and user 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} createdAt - The timestamp when the message was created. - * @property {string | null} [updatedAt] - The timestamp when the message was last updated. - * @property {Member & { user: User }} member - The associated member and user relational data. + * @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 = Message & { - member: Member & { +export type MessageWithMember = Omit & { + channelId?: string; + conversationId?: string; + type?: "chat" | "dm"; + member: { + id: string; + role: string; user: User; }; }; @@ -32,32 +40,47 @@ export type MessageWithMember = Message & { * Properties for the ChatItem component. * * @interface ChatItemProps - * @property {MessageWithMember} message - The message object containing member and user relational data. + * @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 { + type: "chat" | "dm"; message: MessageWithMember; - currentUserId?: string; + currentUserId: string; + onDeleteSuccess?: (id: string) => void; + onEditSuccess?: (id: string, newContent: string) => void; } /** - * Renders an individual chat message row supporting message editing, deletion, and author details. + * Renders an individual chat message row with support for inline editing, deletion, and status indicators. * - * @async * @param {ChatItemProps} props - The component props. - * @param {MessageWithMember} props.message - The message object containing member and user relational data. - * @param {string} [props.currentUserId] - The unique identifier of the currently logged-in user. - * @returns {JSX.Element} The rendered single chat message item. + * @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({ message, currentUserId }: ChatItemProps) { +export function ChatItem({ + type, + message, + currentUserId, + onDeleteSuccess, + onEditSuccess, +}: ChatItemProps) { const router = useRouter(); const [isDeleting, setIsDeleting] = useState(false); const [isEditing, setIsEditing] = useState(false); const [content, setContent] = useState(message.content); const [isLoading, setIsLoading] = useState(false); - const user = message.member?.user; - const fullName = user ? user.username.trim() : "Deleted Member"; + const isDirect = type; + const user = message.member.user; + const fullName = user.username.trim(); const isOwner = user?.id === currentUserId; const isUpdated = message.updatedAt && @@ -72,24 +95,34 @@ export function ChatItem({ message, currentUserId }: ChatItemProps) { }, ); + // Dynamischen Endpunkt basierend auf Chat-Typ bestimmen + const apiEndpoint = isDirect + ? `/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 deletion process completes or fails. + * @returns {Promise} Resolves when the delete operation completes or fails. */ const handleDelete = async () => { if (isDeleting) return; try { setIsDeleting(true); - const response = await fetch(`/api/messages/${message.id}`, { + const response = await fetch(apiEndpoint, { method: "DELETE", }); if (!response.ok) throw new Error("Failed to delete message"); - router.refresh(); + + if (onDeleteSuccess) { + onDeleteSuccess(message.id); + } else { + router.refresh(); + } } catch (error) { console.error("Error deleting the message:", error); } finally { @@ -98,26 +131,32 @@ export function ChatItem({ message, currentUserId }: ChatItemProps) { }; /** - * Handles the asynchronous update of the chat message content. + * Handles the asynchronous update/editing of the chat message content. * * @async * @function handleEdit - * @returns {Promise} Resolves when the update process completes or fails. + * @returns {Promise} Resolves when the edit operation completes or fails. */ const handleEdit = async () => { if (!content.trim() || isLoading) return; try { setIsLoading(true); - const response = await fetch(`/api/messages/${message.id}`, { + const response = await fetch(apiEndpoint, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); if (!response.ok) throw new Error("Failed to update message"); + setIsEditing(false); - router.refresh(); + + if (onEditSuccess) { + onEditSuccess(message.id, content.trim()); + } else { + router.refresh(); + } } catch (error) { console.error("Error editing the message:", error); } finally { diff --git a/components/chat/ChatMessages.tsx b/components/chat/ChatMessages.tsx index 24de231..f72d79c 100644 --- a/components/chat/ChatMessages.tsx +++ b/components/chat/ChatMessages.tsx @@ -1,31 +1,38 @@ /** * @file components/chat/ChatMessages.tsx - * @description Message history container component displaying initial channel greeting, date separators, and a list of individual chat messages. + * @description Scrollable container component that displays message history, handles date dividers, and renders individual chat items for both channels and direct messages. */ "use client"; +import { useState, useEffect, useRef } from "react"; import { ChatItem, type MessageWithMember } from "./ChatItem"; /** * Properties for the ChatMessages component. * * @interface ChatMessagesProps - * @property {string} channelName - The name of the active chat channel to display in the header greeting. - * @property {MessageWithMember[]} messages - Array of message objects, each containing message details and associated member information. + * @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. */ -interface ChatMessagesProps { - channelName: string; - messages: MessageWithMember[]; - currentUserId?: string; +export interface ChatMessagesProps { + type: "chat" | "dm"; + name: string; + initialMessages: MessageWithMember[]; + currentUserId: string; + onDeleteMessage?: (id: string) => void; + onEditMessage?: (id: string, newContent: string) => void; } /** - * Formats a given date string or object into a human-readable label ("Today", "Yesterday", or a localized date string). + * Formats a date string or Date object into a readable label (Today, Yesterday, or formatted date). * * @function formatDateLabel - * @param {string | Date} dateString - The date value to format. + * @param {string | Date} dateString - The date string or object to format. * @returns {string} The formatted date label. */ function formatDateLabel(dateString: string | Date): string { @@ -55,58 +62,130 @@ function formatDateLabel(dateString: string | Date): string { } /** - * Renders the scrollable message list along with a welcoming channel header and dynamic date separators. + * Renders the scrollable message feed with greetings, date dividers, and interactive chat items. * * @param {ChatMessagesProps} props - The component props. - * @param {string} props.channelName - The name of the active chat channel to display in the header greeting. - * @param {MessageWithMember[]} props.messages - Array of message objects, each containing message details and associated member information. - * @param {string} [props.currentUserId] - The unique identifier of the currently logged-in user. + * @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({ - channelName, - messages, + type, + name, + initialMessages, currentUserId, + onDeleteMessage, + onEditMessage, }: ChatMessagesProps) { + const [messages, setMessages] = + useState(initialMessages); + const scrollRef = useRef(null); + + // Synchronisiere den State, wenn der Server neue initialMessages liefert (z.B. nach router.refresh()) + useEffect(() => { + setMessages(initialMessages); + }, [initialMessages]); + + const scrollToBottom = () => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; + + useEffect(() => { + requestAnimationFrame(() => { + scrollToBottom(); + }); + }, [messages]); + + /** + * Handles local state update and triggers parent callback upon successful message deletion. + * + * @function handleDeleteMessage + * @param {string} id - The unique identifier of the deleted message. + * @returns {void} + */ + const handleDeleteMessage = (id: string) => { + setMessages((prev) => prev.filter((m) => m.id !== id)); + onDeleteMessage?.(id); + }; + + /** + * Handles local state update and triggers parent callback upon successful message edit. + * + * @function handleEditMessage + * @param {string} id - The unique identifier of the edited message. + * @param {string} newContent - The updated content text of the message. + * @returns {void} + */ + const handleEditMessage = (id: string, newContent: string) => { + setMessages((prev) => + prev.map((m) => + m.id === id ? { ...m, content: newContent, updatedAt: new Date() } : m, + ), + ); + onEditMessage?.(id, newContent); + }; + return ( -
-
-

- Welcome to #{channelName}! -

-

- This is the beginning of the channel #{channelName}. -

-
+
+
+ {/* Chat Header Greeting */} +
+

+ {type === "dm" + ? `Direct Messages with @${name}` + : `Welcome to #${name}!`} +

+

+ {type === "dm" + ? `This is the start of your direct message history with @${name}.` + : `This is the beginning of the channel #${name}.`} +

+
-
- {messages.map((message, index) => { - const currentDateLabel = formatDateLabel(message.createdAt); - const previousMessage = messages[index - 1]; - const previousDateLabel = previousMessage - ? formatDateLabel(previousMessage.createdAt) - : null; + {/* Message Feed */} +
+ {messages.map((message, index) => { + const currentDateLabel = formatDateLabel(message.createdAt); + const previousMessage = messages[index - 1]; + const previousDateLabel = previousMessage + ? formatDateLabel(previousMessage.createdAt) + : null; - const showDateDivider = currentDateLabel !== previousDateLabel; + const showDateDivider = currentDateLabel !== previousDateLabel; - return ( -
- {/* Date separator */} - {showDateDivider && ( -
-
-
+ return ( +
+ {showDateDivider && ( +
+
+
+
+
+ {currentDateLabel} +
-
- {currentDateLabel} -
-
- )} + )} - -
- ); - })} + +
+ ); + })} +
); diff --git a/components/friends/AllFriendsTab.tsx b/components/friends/AllFriendsTab.tsx index 6b3b0da..b6842ba 100644 --- a/components/friends/AllFriendsTab.tsx +++ b/components/friends/AllFriendsTab.tsx @@ -8,6 +8,7 @@ import { MessageSquare, X } from "lucide-react"; import { UserAvatar } from "../ui/UserAvatar"; import { Friendship, FriendUser } from "./FriendsView"; +import { useRouter } from "next/navigation"; /** * Properties for the AllFriendsTab component. @@ -37,6 +38,25 @@ export function AllFriendsTab({ getFriendUser, onRemove, }: AllFriendsTabProps) { + const router = useRouter(); + + 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(); + router.push(`/dm/${conversation.id}`); + } + } catch (error) { + console.error("Failed to start conversation:", error); + } + }; + return (

@@ -49,12 +69,11 @@ export function AllFriendsTab({
{acceptedFriends.map((f) => { const friend = getFriendUser(f); - const isOnline = friend.status && friend.status !== "OFFLINE"; return (
@@ -64,8 +83,8 @@ export function AllFriendsTab({
{friend.username}
-
- {isOnline ? "Online" : "Offline"} +
+ {friend.status.toLowerCase()}
@@ -73,6 +92,7 @@ export function AllFriendsTab({