feat(chat): integrate direct messaging into ChatMessages component
This commit is contained in:
parent
d94d1ec077
commit
a5c6778c78
6 changed files with 440 additions and 134 deletions
115
app/(app)/dm/[conversationId]/page.tsx
Normal file
115
app/(app)/dm/[conversationId]/page.tsx
Normal file
|
|
@ -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<JSX.Element>} 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 (
|
||||
<div className="flex p-4 flex-col h-full w-full bg-background min-h-0 overflow-hidden">
|
||||
<AppHeader title={partner.username} />
|
||||
|
||||
<ChatMessages
|
||||
type="dm"
|
||||
name={partner.username}
|
||||
initialMessages={initialMessages}
|
||||
currentUserId={session.user.id}
|
||||
/>
|
||||
|
||||
<ChatInput
|
||||
type="dm"
|
||||
conversationId={conversationId}
|
||||
placeholderName={partner.username}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<JSX.Element>} 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<JSX.Element>} 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 (
|
||||
<div className="flex p-4 flex-col h-full bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex p-4 flex-col h-full bg-background min-h-0 overflow-hidden">
|
||||
<AppHeader
|
||||
title={channel.name}
|
||||
showMembersButton
|
||||
server={{ id: server.id, name: server.name }}
|
||||
/>
|
||||
|
||||
{/* Messages Feed */}
|
||||
<ChatMessages
|
||||
channelName={channel.name}
|
||||
messages={channelMessages}
|
||||
type="chat"
|
||||
name={channel.name}
|
||||
initialMessages={channelMessages}
|
||||
currentUserId={session?.user?.id}
|
||||
/>
|
||||
|
||||
{/* Input Field */}
|
||||
<ChatInput
|
||||
type="chat"
|
||||
serverId={server.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
placeholderName={channel.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>} e - The keyboard event.
|
||||
* @async
|
||||
* @function handleKeyDown
|
||||
* @param {React.KeyboardEvent<HTMLInputElement>} e - The keyboard event object.
|
||||
* @returns {Promise<void>} Resolves when the message submission finishes or fails.
|
||||
*/
|
||||
const handleKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="bg-background shrink-0">
|
||||
<div className="bg-surface border border-surface rounded-lg p-2.5 flex items-center focus-within:ring-1 focus-within:ring-accent transition-all">
|
||||
<input
|
||||
type="text"
|
||||
value={content}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface border border-surface rounded-lg p-2.5 flex items-center focus-within:ring-1 focus-within:ring-accent transition-all">
|
||||
<input
|
||||
type="text"
|
||||
value={content}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Message, "channelId"> & {
|
||||
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<void>} Resolves when the deletion process completes or fails.
|
||||
* @returns {Promise<void>} 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<void>} Resolves when the update process completes or fails.
|
||||
* @returns {Promise<void>} 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 {
|
||||
|
|
|
|||
|
|
@ -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<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]);
|
||||
|
||||
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 (
|
||||
<div className="flex-1 overflow-y-auto flex flex-col justify-end mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Welcome to #{channelName}!
|
||||
</h2>
|
||||
<p className="text-muted text-sm">
|
||||
This is the beginning of the channel #{channelName}.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto flex flex-col min-h-0 py-2 mt-4"
|
||||
>
|
||||
<div className="flex flex-col mt-auto mb-4">
|
||||
{/* Chat Header Greeting */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{type === "dm"
|
||||
? `Direct Messages with @${name}`
|
||||
: `Welcome to #${name}!`}
|
||||
</h2>
|
||||
<p className="text-muted text-sm">
|
||||
{type === "dm"
|
||||
? `This is the start of your direct message history with @${name}.`
|
||||
: `This is the beginning of the channel #${name}.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => {
|
||||
const currentDateLabel = formatDateLabel(message.createdAt);
|
||||
const previousMessage = messages[index - 1];
|
||||
const previousDateLabel = previousMessage
|
||||
? formatDateLabel(previousMessage.createdAt)
|
||||
: null;
|
||||
{/* Message Feed */}
|
||||
<div className="space-y-4">
|
||||
{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 (
|
||||
<div className="m-0" key={message.id}>
|
||||
{/* Date separator */}
|
||||
{showDateDivider && (
|
||||
<div className="relative flex items-center justify-center my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-muted/30" />
|
||||
return (
|
||||
<div className="m-0" key={message.id}>
|
||||
{showDateDivider && (
|
||||
<div className="relative flex items-center justify-center my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-muted/30" />
|
||||
</div>
|
||||
<div className="relative bg-background px-2 text-xs font-semibold text-muted rounded-full border border-muted/30">
|
||||
{currentDateLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative bg-background px-2 text-xs font-semibold text-muted rounded-full border border-muted/30">
|
||||
{currentDateLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<ChatItem message={message} currentUserId={currentUserId} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ChatItem
|
||||
type={type}
|
||||
message={message}
|
||||
currentUserId={currentUserId}
|
||||
onDeleteSuccess={handleDeleteMessage}
|
||||
onEditSuccess={handleEditMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
|
||||
|
|
@ -49,12 +69,11 @@ export function AllFriendsTab({
|
|||
<div className="space-y-1">
|
||||
{acceptedFriends.map((f) => {
|
||||
const friend = getFriendUser(f);
|
||||
const isOnline = friend.status && friend.status !== "OFFLINE";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center justify-between p-2.5 rounded-lg hover:bg-surface/50 border-t border-muted/10 group cursor-pointer"
|
||||
className="flex items-center justify-between p-2.5 rounded-lg hover:bg-surface/50 border-t border-muted/10 group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 pr-2">
|
||||
<div className="relative shrink-0">
|
||||
|
|
@ -64,8 +83,8 @@ export function AllFriendsTab({
|
|||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{friend.username}
|
||||
</div>
|
||||
<div className="text-xs text-muted truncate">
|
||||
{isOnline ? "Online" : "Offline"}
|
||||
<div className="text-xs text-muted truncate capitalize">
|
||||
{friend.status.toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,6 +92,7 @@ export function AllFriendsTab({
|
|||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartConversation(friend.id)}
|
||||
title="Message"
|
||||
className="p-2 rounded-full bg-surface hover:bg-muted/30 text-muted hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue