Skip to content
1 change: 0 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { AppRouter } from "@/routes";

export default function App() {
const { state, location, shouldRedirectToAbout, setVisited } = useAppInitialization();

if (shouldRedirectToAbout) {
setVisited();
return <Navigate to="/about" replace />;
Expand Down
34 changes: 30 additions & 4 deletions client/src/components/chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";

import ChatFooter from "@/components/chat/layout/ChatFooter";
import ChatHeader from "@/components/chat/layout/ChatHeader";
import ChatSection from "@/components/chat/layout/ChatSection";
import { Sidebar, SidebarContent } from "@/components/ui/sidebar";

import { useVisible } from "@/hooks/common/useVisible";

import { useChatStore } from "@/store/useChatStore";

export function Chat() {
const { userCount, connect, disconnect, getHistory } = useChatStore();
const { userCount, isConnected, connect, disconnect, getHistory } = useChatStore();
const [isFull, setIsFull] = useState<boolean>(false);
// Socket 연결 관리
const visible = useVisible();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (userCount >= 500) {
setIsFull(true);
Expand All @@ -22,11 +25,34 @@ export function Chat() {
};
}, []);

useEffect(() => {
if (!visible) {
timeoutRef.current = setTimeout(
() => {
disconnect();
},
3 * 60 * 1000
);
} else {
connect();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}

return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [visible]);

return (
<Sidebar side="right" variant="floating">
<SidebarContent>
<ChatHeader />
<ChatSection isFull={isFull} />
<ChatSection isFull={isFull} isConnected={isConnected} />
<ChatFooter />
</SidebarContent>
</Sidebar>
Expand Down
65 changes: 52 additions & 13 deletions client/src/components/chat/ChatItem.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,87 @@
import Avvvatars from "avvvatars-react";
import clsx from "clsx";

import { Avatar } from "@/components/ui/avatar";

import { formatDate } from "@/utils/date";
import { formatTime } from "@/utils/time";

import { useChatStore } from "@/store/useChatStore";
import { ChatType } from "@/types/chat";

type ChatItemProps = {
chatItem: ChatType;
isSameUser: boolean;
};

const chatStyle = "p-3 bg-gray-200 text-black break-words whitespace-pre-wrap rounded-md inline-block max-w-[90%]";
export default function ChatItem({ chatItem, isSameUser }: ChatItemProps) {
const isUser = localStorage.getItem("userID") === chatItem.userId;
const resendMessage = useChatStore((state) => state.resendMessage);
const deleteMessage = useChatStore((state) => state.deleteMessage);
if (chatItem.username === "system")
return <div className="flex justify-center">{formatDate(chatItem.timestamp)}</div>;

return (
<div className="flex flex-col ">
{!isSameUser ? (
<span className="flex gap-1 items-center text-left">
<Avatar>
<Avvvatars value={chatItem.username} style="shape" />
</Avatar>
{/* 이름, 시간 */}
<span className="flex gap-2 items-center inline-block">
<span className="text-sm">{chatItem.username}</span>
<span className="text-xs">{formatTime(chatItem.timestamp)}</span>
<span className={clsx("flex gap-1 items-center", isUser ? "justify-end" : "justify-start")}>
{!isUser && (
<Avatar>
<Avvvatars value={chatItem.username} style="shape" />
</Avatar>
)}

<span className="flex gap-2 items-center">
<span className="text-sm">{isUser ? "나" : chatItem.username}</span>
<span className="text-xs">{chatItem.isFailed ? "전송실패" : formatTime(chatItem.timestamp)}</span>
</span>
</span>
) : (
<></>
)}
<div className="w-full ml-[2rem]">
{!isSameUser ? <FirstChat message={chatItem.message} /> : <OtherChat message={chatItem.message} />}
</div>
{!isUser && (
<div className="w-full ml-[2rem]">
{!isSameUser ? (
<FirstChat message={chatItem.message} isUser={isUser} />
) : (
<OtherChat message={chatItem.message} />
)}
</div>
)}
{isUser && (
<div className="w-full flex justify-end gap-2">
{chatItem.isFailed && (
<div className="flex gap-2">
<button className="hover:text-black" onClick={() => resendMessage(chatItem)}>
재전송
</button>
<button className="hover:text-black" onClick={() => deleteMessage(chatItem.messageId as string)}>
삭제
</button>
</div>
)}
{!isSameUser ? (
<FirstChat message={chatItem.message} isUser={isUser} />
) : (
<OtherChat message={chatItem.message} />
)}
</div>
)}
</div>
);
}

function FirstChat({ message }: { message: string }) {
function FirstChat({ message, isUser }: { message: string; isUser: boolean }) {
return (
<span className={`${chatStyle} relative `}>
{message}
<div className="absolute top-[-5px] left-[0px] w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px]"></div>
<div
className={clsx(
"absolute top-[-5px] w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px]",
isUser ? "right-[0px]" : "left-[0px]"
)}
></div>
</span>
);
}
Expand Down
12 changes: 9 additions & 3 deletions client/src/components/chat/layout/ChatFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useState } from "react";

import { Send } from "lucide-react";

import { Button } from "@/components/ui/button";
Expand All @@ -6,15 +8,19 @@ import { SheetFooter } from "@/components/ui/sheet";

import { useKeyboardShortcut } from "@/hooks/common/useKeyboardShortcut";

import { useChatValueStore, useChatStore } from "@/store/useChatStore";
import { useChatStore } from "@/store/useChatStore";

export default function ChatFooter() {
const { message, setMessage } = useChatValueStore();
const [message, setMessage] = useState<string>("");
const { sendMessage } = useChatStore();

const handleSendMessage = () => {
if (message.trim() !== "") {
sendMessage(message);
sendMessage({
message: message,
messageId: crypto.randomUUID(),
userId: localStorage.getItem("userID") as string,
});
setMessage("");
}
};
Expand Down
56 changes: 56 additions & 0 deletions client/src/components/chat/layout/ChatHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { CircleAlert } from "lucide-react";

import ChatItem from "@/components/chat/ChatItem";
import ChatSkeleton from "@/components/chat/layout/ChatSkeleton";

import Empty from "@/assets/empty-panda.svg";

import { useChatStore } from "@/store/useChatStore";

export default function ChatHistory({ isFull, isConnected }: { isFull: boolean; isConnected: boolean }) {
const { chatHistory, isLoading } = useChatStore();

if (isLoading) return <ChatSkeleton number={14} />;
if (!isConnected) return <NotConnected />;
if (isFull) return <FullChatWarning />;
if (chatHistory.length === 0) return <EmptyChatHistory />;

return (
<span className="flex flex-col gap-3 px-3">
{chatHistory.map((item, index) => {
const isSameUser = index > 0 && chatHistory[index - 1]?.username === item.username;
return <ChatItem key={index} chatItem={item} isSameUser={isSameUser} />;
})}
</span>
);
}

const FullChatWarning = () => (
<div className="flex flex-col justify-center items-center h-[70vh] gap-3">
<CircleAlert color="red" size={200} />
<div className="flex flex-col items-center gap-1">
<p className="font-bold">채팅창 인원이 500명 이상입니다</p>
<p>잠시 기다렸다가 새로고침을 해주세요</p>
</div>
</div>
);

const EmptyChatHistory = () => (
<div className="flex flex-col flex-1 justify-center items-center h-[70vh] gap-3">
<img src={Empty} alt="비어있는 채팅" className="w-[50%] rounded-full" />
<div className="flex flex-col items-center gap-1">
<p className="font-bold">이전 채팅 기록이 없습니다</p>
<p>새로운 채팅을 시작해보세요!!</p>
</div>
</div>
);

const NotConnected = () => (
<div className="flex flex-col justify-center items-center h-[70vh] gap-3">
<CircleAlert color="red" size={200} />
<div className="flex flex-col items-center gap-1">
<p className="font-bold">채팅이 연결되지 않았습니다.</p>
<p>잠시 기다리면 연결이 됩니다.</p>
</div>
</div>
);
61 changes: 6 additions & 55 deletions client/src/components/chat/layout/ChatSection.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,28 @@
import { useEffect, useRef } from "react";

import { CircleAlert } from "lucide-react";

import ChatItem from "@/components/chat/ChatItem";
import ChatSkeleton from "@/components/chat/layout/ChatSkeleton";
import ChatHistory from "@/components/chat/layout/ChatHistory";
import { ScrollArea } from "@/components/ui/scroll-area";

import Empty from "@/assets/empty-panda.svg";

import { useChatStore } from "@/store/useChatStore";
import { ChatType } from "@/types/chat";

const FullChatWarning = () => (
<div className="flex flex-col justify-center items-center h-[70vh] gap-3">
<CircleAlert color="red" size={200} />
<div className="flex flex-col items-center gap-1">
<p className="font-bold">채팅창 인원이 500명 이상입니다</p>
<p>잠시 기다렸다가 새로고침을 해주세요</p>
</div>
</div>
);

const EmptyChatHistory = () => (
<div className="flex flex-col flex-1 justify-center items-center h-[70vh] gap-3">
<img src={Empty} alt="비어있는 채팅" className="w-[50%] rounded-full" />
<div className="flex flex-col items-center gap-1">
<p className="font-bold">이전 채팅 기록이 없습니다</p>
<p>새로운 채팅을 시작해보세요!!</p>
</div>
</div>
);

const RenderHistory = ({
chatHistory,
isFull,
isLoading,
}: {
chatHistory: ChatType[];
isFull: boolean;
isLoading: boolean;
}) => {
if (isLoading) return <ChatSkeleton number={14} />;
if (isFull) return <FullChatWarning />;
if (chatHistory.length === 0) return <EmptyChatHistory />;
return (
<span className="flex flex-col gap-3 px-3">
{chatHistory.map((item, index) => {
const isSameUser = index > 0 && chatHistory[index - 1]?.username === item.username;
return <ChatItem key={index} chatItem={item} isSameUser={isSameUser} />;
})}
</span>
);
};

export default function ChatSection({ isFull }: { isFull: boolean }) {
export default function ChatSection({ isFull, isConnected }: { isFull: boolean; isConnected: boolean }) {
const scrollRef = useRef<HTMLDivElement>(null);
const { chatHistory, isLoading } = useChatStore();
const chatLength = useChatStore((state) => state.chatLength);

useEffect(() => {
if (scrollRef.current) {
const scrollContent = scrollRef.current.querySelector("[data-radix-scroll-area-viewport]");
if (scrollContent && chatHistory.length > 0) {
if (scrollContent && chatLength() > 0) {
scrollContent.scrollTo({
top: scrollContent.scrollHeight,
behavior: "smooth",
});
}
}
}, [chatHistory.length]);

}, [chatLength()]);
return (
<ScrollArea ref={scrollRef} className="h-full">
<RenderHistory chatHistory={chatHistory} isFull={isFull} isLoading={isLoading} />
<ChatHistory isFull={isFull} isConnected={isConnected} />
</ScrollArea>
);
}
16 changes: 16 additions & 0 deletions client/src/hooks/common/useVisible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";

export const useVisible = () => {
const [visible, setVisible] = useState<boolean>(true);
useEffect(() => {
const handleVisible = () => {
const isVisible = document.visibilityState === "visible";
setVisible(isVisible);
};
document.addEventListener("visibilitychange", handleVisible);
return () => {
document.removeEventListener("visibilitychange", handleVisible);
};
}, []);
return visible;
};
Loading