Skip to content

✨ feat: 채팅 가시성 관련 웹 소켓 연결 관리 #402

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
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