feat(chat): integrate direct messaging into ChatMessages component

This commit is contained in:
Chneemann 2026-09-05 10:56:04 +02:00
parent d94d1ec077
commit a5c6778c78
No known key found for this signature in database
6 changed files with 440 additions and 134 deletions

View 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>
);
}

View file

@ -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>
);

View file

@ -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>
);
}

View file

@ -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 {

View file

@ -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>
);

View file

@ -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"
>