From ce003a255aaf01ce52b8faf5153d21bbaaa7c3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=AA=85=EA=B8=B0?= Date: Thu, 24 Apr 2025 23:41:40 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=A0=84=EC=97=AD=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/chat/layout/ChatFooter.tsx | 6 ++- client/src/store/useChatStore.ts | 38 +++++-------------- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/client/src/components/chat/layout/ChatFooter.tsx b/client/src/components/chat/layout/ChatFooter.tsx index 2e8b456d..1f3d667c 100644 --- a/client/src/components/chat/layout/ChatFooter.tsx +++ b/client/src/components/chat/layout/ChatFooter.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import { Send } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -6,10 +8,10 @@ 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(""); const { sendMessage } = useChatStore(); const handleSendMessage = () => { diff --git a/client/src/store/useChatStore.ts b/client/src/store/useChatStore.ts index a3fbb5c3..9f0252ce 100644 --- a/client/src/store/useChatStore.ts +++ b/client/src/store/useChatStore.ts @@ -5,46 +5,43 @@ import { CHAT_SERVER_URL } from "@/constants/endpoints"; import { ChatType } from "@/types/chat"; -interface ChatStore { +type State = { chatHistory: ChatType[]; userCount: number; isLoading: boolean; +}; +type Action = { connect: () => void; disconnect: () => void; getHistory: () => void; sendMessage: (message: string) => void; -} +}; -export const useChatStore = create((set) => { +export const useChatStore = create((set) => { let socket: Socket | null = null; - // 소켓 초기화 함수 const initializeSocket = () => { - if (socket) return socket; // 이미 존재하면 그대로 반환 + if (socket) return socket; socket = io(CHAT_SERVER_URL, { path: "/chat", transports: ["websocket"], - reconnection: true, // 자동 재연결 활성화 - reconnectionAttempts: 5, // 최대 5번 재시도 - reconnectionDelay: 1000, // 1초 간격으로 재시도 + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, }); - // 서버 연결 성공 시 socket.on("connect", () => {}); - // 서버로부터 메시지 받기 socket.on("message", (data) => { set((state) => ({ chatHistory: [...state.chatHistory, data], })); }); - // 사용자 수 업데이트 받기 socket.on("updateUserCount", (data) => { set({ userCount: data.userCount }); }); - // 서버 연결 해제 시 socket.on("disconnect", () => {}); return socket; @@ -54,19 +51,16 @@ export const useChatStore = create((set) => { chatHistory: [], userCount: 0, isLoading: true, - // Socket 연결 함수 connect: () => { - if (socket) return; // 이미 연결된 경우 중복 방지 + if (socket) return; initializeSocket(); }, - // Socket 연결 해제 함수 disconnect: () => { socket?.disconnect(); socket = null; }, - // 이전 채팅 기록 받아오기 getHistory: () => { if (socket) { socket.emit("getHistory"); @@ -82,12 +76,10 @@ export const useChatStore = create((set) => { } }, - // 메시지 전송 함수 sendMessage: (message: string) => { if (socket) { socket.emit("message", { message }); } else { - // 소켓이 없으면 연결 후 메시지 전송 const newSocket = initializeSocket(); newSocket.on("connect", () => { newSocket.emit("message", { message }); @@ -96,13 +88,3 @@ export const useChatStore = create((set) => { }, }; }); - -interface ChatValue { - message: string; - setMessage: (newMessage: string) => void; -} - -export const useChatValueStore = create((set) => ({ - message: "", - setMessage: (newMessage: string) => set({ message: newMessage }), -})); From d73315ed3de41dca514084a9147999dbedd15a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=AA=85=EA=B8=B0?= Date: Sat, 26 Apr 2025 00:45:23 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=91=EA=B7=BC=EC=8B=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EB=9E=9C=EB=8D=A4ID=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index f28f2525..1c8edbc3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -30,6 +30,7 @@ export default function App() { const isMobile = useMediaQuery("(max-width: 767px)"); const { hasVisited, setVisited } = useVisitStore(); const location = useLocation(); + const userID = localStorage.getItem("userID"); const state = location.state && location.state.backgroundLocation ? { backgroundLocation: location.state.backgroundLocation } @@ -37,8 +38,8 @@ export default function App() { useEffect(() => { console.log(denamuAscii); + if (!userID) localStorage.setItem("userID", crypto.randomUUID()); }, []); - useEffect(() => { if (location.state?.backgroundLocation) { window.history.replaceState({}, document.title, window.location.pathname + window.location.search); From a7ddc956e23f3e06c9d602e9d9f925f2851c2e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=AA=85=EA=B8=B0?= Date: Tue, 29 Apr 2025 20:01:39 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EA=B0=80=20=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=203=EB=B6=84=EC=9D=B4=EC=83=81=20=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EA=B0=88=20=EA=B2=BD=EC=9A=B0=20=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=81=8A=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/chat/Chat.tsx | 30 ++++++++++++- client/src/hooks/common/useVisible.ts | 16 +++++++ client/src/store/useChatStore.ts | 64 ++++++++++++++++----------- client/src/types/chat.ts | 8 ++++ 4 files changed, 91 insertions(+), 27 deletions(-) create mode 100644 client/src/hooks/common/useVisible.ts diff --git a/client/src/components/chat/Chat.tsx b/client/src/components/chat/Chat.tsx index 772d6906..55d018b8 100644 --- a/client/src/components/chat/Chat.tsx +++ b/client/src/components/chat/Chat.tsx @@ -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 [isFull, setIsFull] = useState(false); - // Socket 연결 관리 + const visible = useVisible(); + const timeoutRef = useRef(null); useEffect(() => { if (userCount >= 500) { setIsFull(true); @@ -22,6 +25,29 @@ 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 ( diff --git a/client/src/hooks/common/useVisible.ts b/client/src/hooks/common/useVisible.ts new file mode 100644 index 00000000..3454ecf9 --- /dev/null +++ b/client/src/hooks/common/useVisible.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +export const useVisible = () => { + const [visible, setVisible] = useState(true); + useEffect(() => { + const handleVisible = () => { + const isVisible = document.visibilityState === "visible"; + setVisible(isVisible); + }; + document.addEventListener("visibilitychange", handleVisible); + return () => { + document.removeEventListener("visibilitychange", handleVisible); + }; + }, []); + return visible; +}; diff --git a/client/src/store/useChatStore.ts b/client/src/store/useChatStore.ts index 9f0252ce..6b015e72 100644 --- a/client/src/store/useChatStore.ts +++ b/client/src/store/useChatStore.ts @@ -3,22 +3,24 @@ import { create } from "zustand"; import { CHAT_SERVER_URL } from "@/constants/endpoints"; -import { ChatType } from "@/types/chat"; +import { ChatType, SendChatType } from "@/types/chat"; + +let socket: Socket | null = null; type State = { chatHistory: ChatType[]; userCount: number; isLoading: boolean; + isConnected: boolean; }; type Action = { connect: () => void; disconnect: () => void; getHistory: () => void; - sendMessage: (message: string) => void; + sendMessage: (message: SendChatType) => void; }; export const useChatStore = create((set) => { - let socket: Socket | null = null; const initializeSocket = () => { if (socket) return socket; @@ -28,10 +30,9 @@ export const useChatStore = create((set) => { reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000, + autoConnect: false, }); - socket.on("connect", () => {}); - socket.on("message", (data) => { set((state) => ({ chatHistory: [...state.chatHistory, data], @@ -41,8 +42,13 @@ export const useChatStore = create((set) => { socket.on("updateUserCount", (data) => { set({ userCount: data.userCount }); }); + socket.on("connect", () => { + useChatStore.setState({ isConnected: true }); + }); - socket.on("disconnect", () => {}); + socket.on("disconnect", () => { + useChatStore.setState({ isConnected: false }); + }); return socket; }; @@ -51,39 +57,47 @@ export const useChatStore = create((set) => { chatHistory: [], userCount: 0, isLoading: true, + isConnected: false, connect: () => { - if (socket) return; - initializeSocket(); + const s = initializeSocket(); + if (!s.connected) { + s.connect(); + } }, disconnect: () => { socket?.disconnect(); - socket = null; }, getHistory: () => { - if (socket) { - socket.emit("getHistory"); - socket.on("chatHistory", (data) => { - set(() => ({ - chatHistory: data, - isLoading: false, - })); - }); + const s = initializeSocket(); + if (s.connected) { + s.emit("getHistory"); } else { - const newSocket = initializeSocket(); - newSocket.emit("getHistory"); + s.once("connect", () => { + s.emit("getHistory"); + }); + s.connect(); } + + s.on("chatHistory", (data) => { + console.log(data); + useChatStore.setState({ + chatHistory: data, + isLoading: false, + }); + }); }, - sendMessage: (message: string) => { - if (socket) { - socket.emit("message", { message }); + sendMessage: (message: SendChatType) => { + const s = initializeSocket(); + if (s.connected) { + s.emit("message", message); } else { - const newSocket = initializeSocket(); - newSocket.on("connect", () => { - newSocket.emit("message", { message }); + s.once("connect", () => { + s.emit("message", message); }); + s.connect(); } }, }; diff --git a/client/src/types/chat.ts b/client/src/types/chat.ts index d23b161c..3c7334ab 100644 --- a/client/src/types/chat.ts +++ b/client/src/types/chat.ts @@ -4,4 +4,12 @@ export type ChatType = { timestamp: string; message: string; isMidNight?: boolean; + userId?: string; + messageId?: string; +}; + +export type SendChatType = { + message: string; + userId: string; + messageId: string; }; From 06384b0dbc04778ddc5f222b877634c8683a9714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=AA=85=EA=B8=B0?= Date: Tue, 29 Apr 2025 20:02:25 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EC=9D=B4=20=EC=95=88=EB=90=98=EC=96=B4?= =?UTF-8?q?=EC=9E=88=EC=9D=84=20=EA=B2=BD=EC=9A=B0=20=EB=8C=80=EA=B8=B0UI?= =?UTF-8?q?=20=EB=A0=8C=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/chat/layout/ChatSection.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/client/src/components/chat/layout/ChatSection.tsx b/client/src/components/chat/layout/ChatSection.tsx index 502c546b..c8b21a0c 100644 --- a/client/src/components/chat/layout/ChatSection.tsx +++ b/client/src/components/chat/layout/ChatSection.tsx @@ -31,16 +31,29 @@ const EmptyChatHistory = () => ( ); +const NotConnected = () => ( +
+ +
+

채팅이 연결되지 않았습니다.

+

잠시 기다리면 연결이 됩니다.

+
+
+); + const RenderHistory = ({ chatHistory, isFull, isLoading, + isConnected, }: { chatHistory: ChatType[]; isFull: boolean; isLoading: boolean; + isConnected: boolean; }) => { if (isLoading) return ; + if (!isConnected) return ; if (isFull) return ; if (chatHistory.length === 0) return ; return ( @@ -55,7 +68,7 @@ const RenderHistory = ({ export default function ChatSection({ isFull }: { isFull: boolean }) { const scrollRef = useRef(null); - const { chatHistory, isLoading } = useChatStore(); + const { chatHistory, isLoading, isConnected } = useChatStore(); useEffect(() => { if (scrollRef.current) { @@ -71,7 +84,7 @@ export default function ChatSection({ isFull }: { isFull: boolean }) { return ( - + ); } From c6fe1ed0751c45120e8eddf13c2b4a6bd71ed3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=AA=85=EA=B8=B0?= Date: Tue, 29 Apr 2025 20:03:05 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=92=84=20style:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EB=B3=B4=EB=82=B8=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=EC=97=90=20=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/chat/ChatItem.tsx | 32 +++++++++++++------ .../src/components/chat/layout/ChatFooter.tsx | 6 +++- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/client/src/components/chat/ChatItem.tsx b/client/src/components/chat/ChatItem.tsx index 2879d7c6..1a5702a2 100644 --- a/client/src/components/chat/ChatItem.tsx +++ b/client/src/components/chat/ChatItem.tsx @@ -1,4 +1,5 @@ import Avvvatars from "avvvatars-react"; +import clsx from "clsx"; import { Avatar } from "@/components/ui/avatar"; @@ -11,29 +12,40 @@ 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; if (chatItem.username === "system") return
{formatDate(chatItem.timestamp)}
; return (
{!isSameUser ? ( - - - - - {/* 이름, 시간 */} - - {chatItem.username} + + {!isUser && ( + + + + )} + + + {isUser ? "나" : chatItem.username} {formatTime(chatItem.timestamp)} ) : ( <> )} -
- {!isSameUser ? : } -
+ {!isUser && ( +
+ {!isSameUser ? : } +
+ )} + {isUser && ( +
+ {!isSameUser ? : } +
+ )}
); } diff --git a/client/src/components/chat/layout/ChatFooter.tsx b/client/src/components/chat/layout/ChatFooter.tsx index 1f3d667c..6c2500d8 100644 --- a/client/src/components/chat/layout/ChatFooter.tsx +++ b/client/src/components/chat/layout/ChatFooter.tsx @@ -16,7 +16,11 @@ export default function ChatFooter() { const handleSendMessage = () => { if (message.trim() !== "") { - sendMessage(message); + sendMessage({ + message: message, + messageId: crypto.randomUUID(), + userId: localStorage.getItem("userID") as string, + }); setMessage(""); } }; From 305d3195fc6895421eca0f234eda12833ca0ddde Mon Sep 17 00:00:00 2001 From: jungmyunggi Date: Tue, 29 Apr 2025 20:23:12 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=92=84=20style:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EB=B3=B4=EB=82=B8=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=EC=97=90=20=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/chat/ChatItem.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/client/src/components/chat/ChatItem.tsx b/client/src/components/chat/ChatItem.tsx index 1a5702a2..b2bd0eff 100644 --- a/client/src/components/chat/ChatItem.tsx +++ b/client/src/components/chat/ChatItem.tsx @@ -38,23 +38,36 @@ export default function ChatItem({ chatItem, isSameUser }: ChatItemProps) { )} {!isUser && (
- {!isSameUser ? : } + {!isSameUser ? ( + + ) : ( + + )}
)} {isUser && (
- {!isSameUser ? : } + {!isSameUser ? ( + + ) : ( + + )}
)} ); } -function FirstChat({ message }: { message: string }) { +function FirstChat({ message, isUser }: { message: string; isUser: boolean }) { return ( {message} -
+
); } From 72763e3c3cb70f0fb28281df6148c9e29b4d0513 Mon Sep 17 00:00:00 2001 From: jungmyunggi Date: Thu, 29 May 2025 00:36:35 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EA=B0=80=EC=8B=9C=EC=84=B1=20=EA=B4=80=EB=A0=A8=20=EC=9B=B9=20?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=97=B0=EA=B2=B0=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/chat/Chat.tsx | 4 +- client/src/components/chat/ChatItem.tsx | 18 ++- .../components/chat/layout/ChatHistory.tsx | 56 +++++++++ .../components/chat/layout/ChatSection.tsx | 74 +----------- client/src/store/useChatStore.ts | 107 +++++++++++++++--- client/src/types/chat.ts | 2 + 6 files changed, 173 insertions(+), 88 deletions(-) create mode 100644 client/src/components/chat/layout/ChatHistory.tsx diff --git a/client/src/components/chat/Chat.tsx b/client/src/components/chat/Chat.tsx index 55d018b8..d1b40801 100644 --- a/client/src/components/chat/Chat.tsx +++ b/client/src/components/chat/Chat.tsx @@ -10,7 +10,7 @@ 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(false); const visible = useVisible(); const timeoutRef = useRef(null); @@ -52,7 +52,7 @@ export function Chat() { - + diff --git a/client/src/components/chat/ChatItem.tsx b/client/src/components/chat/ChatItem.tsx index b2bd0eff..6a6d752c 100644 --- a/client/src/components/chat/ChatItem.tsx +++ b/client/src/components/chat/ChatItem.tsx @@ -6,6 +6,7 @@ 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 = { @@ -16,8 +17,11 @@ type ChatItemProps = { 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
{formatDate(chatItem.timestamp)}
; + return (
{!isSameUser ? ( @@ -30,7 +34,7 @@ export default function ChatItem({ chatItem, isSameUser }: ChatItemProps) { {isUser ? "나" : chatItem.username} - {formatTime(chatItem.timestamp)} + {chatItem.isFailed ? "전송실패" : formatTime(chatItem.timestamp)} ) : ( @@ -46,7 +50,17 @@ export default function ChatItem({ chatItem, isSameUser }: ChatItemProps) {
)} {isUser && ( -
+
+ {chatItem.isFailed && ( +
+ + +
+ )} {!isSameUser ? ( ) : ( diff --git a/client/src/components/chat/layout/ChatHistory.tsx b/client/src/components/chat/layout/ChatHistory.tsx new file mode 100644 index 00000000..c7cdcf09 --- /dev/null +++ b/client/src/components/chat/layout/ChatHistory.tsx @@ -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 ; + if (!isConnected) return ; + if (isFull) return ; + if (chatHistory.length === 0) return ; + + return ( + + {chatHistory.map((item, index) => { + const isSameUser = index > 0 && chatHistory[index - 1]?.username === item.username; + return ; + })} + + ); +} + +const FullChatWarning = () => ( +
+ +
+

채팅창 인원이 500명 이상입니다

+

잠시 기다렸다가 새로고침을 해주세요

+
+
+); + +const EmptyChatHistory = () => ( +
+ 비어있는 채팅 +
+

이전 채팅 기록이 없습니다

+

새로운 채팅을 시작해보세요!!

+
+
+); + +const NotConnected = () => ( +
+ +
+

채팅이 연결되지 않았습니다.

+

잠시 기다리면 연결이 됩니다.

+
+
+); diff --git a/client/src/components/chat/layout/ChatSection.tsx b/client/src/components/chat/layout/ChatSection.tsx index c8b21a0c..bbd3883e 100644 --- a/client/src/components/chat/layout/ChatSection.tsx +++ b/client/src/components/chat/layout/ChatSection.tsx @@ -1,90 +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 = () => ( -
- -
-

채팅창 인원이 500명 이상입니다

-

잠시 기다렸다가 새로고침을 해주세요

-
-
-); - -const EmptyChatHistory = () => ( -
- 비어있는 채팅 -
-

이전 채팅 기록이 없습니다

-

새로운 채팅을 시작해보세요!!

-
-
-); - -const NotConnected = () => ( -
- -
-

채팅이 연결되지 않았습니다.

-

잠시 기다리면 연결이 됩니다.

-
-
-); -const RenderHistory = ({ - chatHistory, - isFull, - isLoading, - isConnected, -}: { - chatHistory: ChatType[]; - isFull: boolean; - isLoading: boolean; - isConnected: boolean; -}) => { - if (isLoading) return ; - if (!isConnected) return ; - if (isFull) return ; - if (chatHistory.length === 0) return ; - return ( - - {chatHistory.map((item, index) => { - const isSameUser = index > 0 && chatHistory[index - 1]?.username === item.username; - return ; - })} - - ); -}; - -export default function ChatSection({ isFull }: { isFull: boolean }) { +export default function ChatSection({ isFull, isConnected }: { isFull: boolean; isConnected: boolean }) { const scrollRef = useRef(null); - const { chatHistory, isLoading, isConnected } = 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 ( - + ); } diff --git a/client/src/store/useChatStore.ts b/client/src/store/useChatStore.ts index 6b015e72..5052cd6c 100644 --- a/client/src/store/useChatStore.ts +++ b/client/src/store/useChatStore.ts @@ -6,6 +6,7 @@ import { CHAT_SERVER_URL } from "@/constants/endpoints"; import { ChatType, SendChatType } from "@/types/chat"; let socket: Socket | null = null; +const pendingTimeouts: Record = {}; type State = { chatHistory: ChatType[]; @@ -18,12 +19,14 @@ type Action = { disconnect: () => void; getHistory: () => void; sendMessage: (message: SendChatType) => void; + resendMessage: (data: ChatType) => void; + deleteMessage: (messageId: string) => void; + chatLength: () => number; }; -export const useChatStore = create((set) => { +export const useChatStore = create((set, get) => { const initializeSocket = () => { if (socket) return socket; - socket = io(CHAT_SERVER_URL, { path: "/chat", transports: ["websocket"], @@ -34,9 +37,20 @@ export const useChatStore = create((set) => { }); socket.on("message", (data) => { - set((state) => ({ - chatHistory: [...state.chatHistory, data], - })); + if (pendingTimeouts[data.messageId]) { + clearTimeout(pendingTimeouts[data.messageId]); + delete pendingTimeouts[data.messageId]; + } + + set((state) => { + const index = state.chatHistory.findIndex((msg) => msg.messageId === data.messageId); + if (index !== -1) { + const newHistory = [...state.chatHistory]; + newHistory[index] = { ...data, isSend: true, isFailed: false }; + return { chatHistory: newHistory }; + } + return { chatHistory: [...state.chatHistory, { ...data, isSend: true, isFailed: false }] }; + }); }); socket.on("updateUserCount", (data) => { @@ -71,34 +85,95 @@ export const useChatStore = create((set) => { getHistory: () => { const s = initializeSocket(); - if (s.connected) { + + const requestHistory = () => { s.emit("getHistory"); + s.off("connect", requestHistory); + }; + if (s.connected) { + requestHistory(); } else { - s.once("connect", () => { - s.emit("getHistory"); - }); + s.on("connect", requestHistory); s.connect(); } s.on("chatHistory", (data) => { - console.log(data); - useChatStore.setState({ - chatHistory: data, - isLoading: false, + useChatStore.setState((state) => { + const failedMessages = state.chatHistory.filter((chat) => chat.isFailed || !chat.isSend); + return { + chatHistory: [...data, ...failedMessages], + isLoading: false, + }; }); }); }, sendMessage: (message: SendChatType) => { const s = initializeSocket(); + + useChatStore.setState((state) => ({ + chatHistory: [ + ...state.chatHistory, + { + timestamp: "전송중", + username: "나", + isMidNight: false, + message: message.message, + messageId: message.messageId, + userId: localStorage.getItem("userID"), + } as ChatType, + ], + })); + + pendingTimeouts[message.messageId] = setTimeout(() => { + useChatStore.setState((state) => ({ + chatHistory: state.chatHistory.map((m) => (m.messageId === message.messageId ? { ...m, isFailed: true } : m)), + })); + delete pendingTimeouts[message.messageId]; + }, 5000); + if (s.connected) { s.emit("message", message); } else { - s.once("connect", () => { - s.emit("message", message); - }); s.connect(); } }, + + resendMessage: (data: ChatType) => { + const s = initializeSocket(); + if (s.connected) { + s.emit("message", { + message: data.message, + messageId: data.messageId, + userId: data.userId, + }); + useChatStore.setState((state) => ({ + chatHistory: state.chatHistory.map((m) => + m.messageId === data.messageId ? { ...m, isFailed: false, timestamp: "전송중", isSend: false } : m + ), + })); + + pendingTimeouts[data.messageId as string] = setTimeout(() => { + useChatStore.setState((state) => ({ + chatHistory: state.chatHistory.map((m) => (m.messageId === data.messageId ? { ...m, isFailed: true } : m)), + })); + delete pendingTimeouts[data.messageId as string]; + }, 5000); + } else { + console.error("소켓이 끊겼습니다. 재전송할 수 없습니다."); + alert("지금은 연결이 끊겨 재전송할 수 없습니다."); + } + }, + deleteMessage: (messageId: string) => { + if (pendingTimeouts[messageId]) { + clearTimeout(pendingTimeouts[messageId]); + delete pendingTimeouts[messageId]; + } + useChatStore.setState((state) => ({ + chatHistory: state.chatHistory.filter((m) => m.messageId !== messageId), + })); + alert("메시지가 삭제되었습니다"); + }, + chatLength: () => get().chatHistory.length, }; }); diff --git a/client/src/types/chat.ts b/client/src/types/chat.ts index 3c7334ab..c9fcdc6f 100644 --- a/client/src/types/chat.ts +++ b/client/src/types/chat.ts @@ -6,6 +6,8 @@ export type ChatType = { isMidNight?: boolean; userId?: string; messageId?: string; + isSend?: boolean; + isFailed?: boolean; }; export type SendChatType = {