diff --git a/client/src/App.tsx b/client/src/App.tsx index 3e5d627e..97978269 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,7 +9,6 @@ import { AppRouter } from "@/routes"; export default function App() { const { state, location, shouldRedirectToAbout, setVisited } = useAppInitialization(); - if (shouldRedirectToAbout) { setVisited(); return ; diff --git a/client/src/components/chat/Chat.tsx b/client/src/components/chat/Chat.tsx index 772d6906..d1b40801 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 { userCount, isConnected, connect, disconnect, getHistory } = useChatStore(); const [isFull, setIsFull] = useState(false); - // Socket 연결 관리 + const visible = useVisible(); + const timeoutRef = useRef(null); useEffect(() => { if (userCount >= 500) { setIsFull(true); @@ -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 ( - + diff --git a/client/src/components/chat/ChatItem.tsx b/client/src/components/chat/ChatItem.tsx index 2879d7c6..6a6d752c 100644 --- a/client/src/components/chat/ChatItem.tsx +++ b/client/src/components/chat/ChatItem.tsx @@ -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 {formatDate(chatItem.timestamp)}; + return ( {!isSameUser ? ( - - - - - {/* 이름, 시간 */} - - {chatItem.username} - {formatTime(chatItem.timestamp)} + + {!isUser && ( + + + + )} + + + {isUser ? "나" : chatItem.username} + {chatItem.isFailed ? "전송실패" : formatTime(chatItem.timestamp)} ) : ( <>> )} - - {!isSameUser ? : } - + {!isUser && ( + + {!isSameUser ? ( + + ) : ( + + )} + + )} + {isUser && ( + + {chatItem.isFailed && ( + + resendMessage(chatItem)}> + 재전송 + + deleteMessage(chatItem.messageId as string)}> + 삭제 + + + )} + {!isSameUser ? ( + + ) : ( + + )} + + )} ); } -function FirstChat({ message }: { message: string }) { +function FirstChat({ message, isUser }: { message: string; isUser: boolean }) { return ( {message} - + ); } diff --git a/client/src/components/chat/layout/ChatFooter.tsx b/client/src/components/chat/layout/ChatFooter.tsx index 2e8b456d..6c2500d8 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,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(""); const { sendMessage } = useChatStore(); const handleSendMessage = () => { if (message.trim() !== "") { - sendMessage(message); + sendMessage({ + message: message, + messageId: crypto.randomUUID(), + userId: localStorage.getItem("userID") as string, + }); setMessage(""); } }; 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 502c546b..bbd3883e 100644 --- a/client/src/components/chat/layout/ChatSection.tsx +++ b/client/src/components/chat/layout/ChatSection.tsx @@ -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 = () => ( - - - - 채팅창 인원이 500명 이상입니다 - 잠시 기다렸다가 새로고침을 해주세요 - - -); - -const EmptyChatHistory = () => ( - - - - 이전 채팅 기록이 없습니다 - 새로운 채팅을 시작해보세요!! - - -); -const RenderHistory = ({ - chatHistory, - isFull, - isLoading, -}: { - chatHistory: ChatType[]; - isFull: boolean; - isLoading: boolean; -}) => { - if (isLoading) 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 } = 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/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 a3fbb5c3..5052cd6c 100644 --- a/client/src/store/useChatStore.ts +++ b/client/src/store/useChatStore.ts @@ -3,49 +3,66 @@ import { create } from "zustand"; import { CHAT_SERVER_URL } from "@/constants/endpoints"; -import { ChatType } from "@/types/chat"; +import { ChatType, SendChatType } from "@/types/chat"; -interface ChatStore { +let socket: Socket | null = null; +const pendingTimeouts: Record = {}; + +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; + resendMessage: (data: ChatType) => void; + deleteMessage: (messageId: string) => void; + chatLength: () => number; +}; -export const useChatStore = create((set) => { - let socket: Socket | null = null; - // 소켓 초기화 함수 +export const useChatStore = create((set, get) => { 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, + autoConnect: false, }); - // 서버 연결 성공 시 - socket.on("connect", () => {}); - - // 서버로부터 메시지 받기 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) => { set({ userCount: data.userCount }); }); + socket.on("connect", () => { + useChatStore.setState({ isConnected: true }); + }); - // 서버 연결 해제 시 - socket.on("disconnect", () => {}); + socket.on("disconnect", () => { + useChatStore.setState({ isConnected: false }); + }); return socket; }; @@ -54,55 +71,109 @@ export const useChatStore = create((set) => { chatHistory: [], userCount: 0, isLoading: true, - // Socket 연결 함수 + isConnected: false, connect: () => { - if (socket) return; // 이미 연결된 경우 중복 방지 - initializeSocket(); + const s = initializeSocket(); + if (!s.connected) { + s.connect(); + } }, - // Socket 연결 해제 함수 disconnect: () => { socket?.disconnect(); - socket = null; }, - // 이전 채팅 기록 받아오기 getHistory: () => { - if (socket) { - socket.emit("getHistory"); - socket.on("chatHistory", (data) => { - set(() => ({ - chatHistory: data, + const s = initializeSocket(); + + const requestHistory = () => { + s.emit("getHistory"); + s.off("connect", requestHistory); + }; + if (s.connected) { + requestHistory(); + } else { + s.on("connect", requestHistory); + s.connect(); + } + + s.on("chatHistory", (data) => { + 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 { - const newSocket = initializeSocket(); - newSocket.emit("getHistory"); + s.connect(); } }, - // 메시지 전송 함수 - sendMessage: (message: string) => { - if (socket) { - socket.emit("message", { message }); - } else { - // 소켓이 없으면 연결 후 메시지 전송 - const newSocket = initializeSocket(); - newSocket.on("connect", () => { - newSocket.emit("message", { message }); + 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, }; }); - -interface ChatValue { - message: string; - setMessage: (newMessage: string) => void; -} - -export const useChatValueStore = create((set) => ({ - message: "", - setMessage: (newMessage: string) => set({ message: newMessage }), -})); diff --git a/client/src/types/chat.ts b/client/src/types/chat.ts index d23b161c..c9fcdc6f 100644 --- a/client/src/types/chat.ts +++ b/client/src/types/chat.ts @@ -4,4 +4,14 @@ export type ChatType = { timestamp: string; message: string; isMidNight?: boolean; + userId?: string; + messageId?: string; + isSend?: boolean; + isFailed?: boolean; +}; + +export type SendChatType = { + message: string; + userId: string; + messageId: string; };
채팅창 인원이 500명 이상입니다
잠시 기다렸다가 새로고침을 해주세요
이전 채팅 기록이 없습니다
새로운 채팅을 시작해보세요!!
채팅이 연결되지 않았습니다.
잠시 기다리면 연결이 됩니다.