diff --git a/src/entities/chat/lib/useChatMessages.ts b/src/entities/chat/lib/useChatMessages.ts new file mode 100644 index 00000000..b113ffeb --- /dev/null +++ b/src/entities/chat/lib/useChatMessages.ts @@ -0,0 +1,90 @@ +import { useEffect, useState, useRef } from "react"; +import { apiFetch } from "@/shared/api/fetcher"; +import { MessagesResponse, MessageProps } from "../model/types"; +import { useModalStore } from "@/shared/model/modal.store"; + +export const useChatMessages = (chatId: number | null) => { + const [messages, setMessages] = useState([]); + const [cursor, setCursor] = useState(null); + const [hasNext, setHasNext] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const { openModal, closeModal } = useModalStore(); + + const scrollContainerRef = useRef(null); + const messagesEndRef = useRef(null); + + // 초기 메시지 + useEffect(() => { + if (!chatId) return; + + async function fetchMessages() { + try { + setIsLoading(true); + const res = await apiFetch( + `/api/chat/${chatId}?size=20`, + { method: "GET" }, + ); + + setMessages(res.messages.reverse()); + setCursor(res.nextCursor); + setHasNext(res.hasNext); + + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "auto" }); + } + } catch { + openModal("normal", { + message: "메시지 조회에 실패했습니다.", + onClick: () => closeModal(), + }); + } finally { + setIsLoading(false); + } + } + + fetchMessages(); + }, [chatId]); + + // 이전 메시지 추가 로드 + const fetchMoreMessages = async () => { + if (!chatId || !hasNext || isLoading) return; + + const container = scrollContainerRef.current; + const prevHeight = container?.scrollHeight ?? 0; + + try { + setIsLoading(true); + const res = await apiFetch( + `/api/chat/${chatId}?cursor=${cursor}&size=20`, + { method: "GET" }, + ); + setMessages((prev) => [...[...res.messages].reverse(), ...prev]); + setCursor(res.nextCursor); + setHasNext(res.hasNext); + //스크롤 높이 조정 + if (container) { + requestAnimationFrame(() => { + const newHeight = container.scrollHeight; + container.scrollTop += newHeight - prevHeight; + }); + } + } catch { + openModal("normal", { + message: "이전 메시지를 불러오는데 실패했습니다.", + onClick: () => closeModal(), + }); + } finally { + setIsLoading(false); + } + }; + + return { + messages, + setMessages, + isLoading, + hasNext, + fetchMoreMessages, + scrollContainerRef, + messagesEndRef, + }; +}; diff --git a/src/entities/chat/lib/useChatOtherUser.ts b/src/entities/chat/lib/useChatOtherUser.ts new file mode 100644 index 00000000..c14015ba --- /dev/null +++ b/src/entities/chat/lib/useChatOtherUser.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import { User } from "@/entities/user/model/types/user"; +import { apiFetch } from "@/shared/api/fetcher"; +import { useModalStore } from "@/shared/model/modal.store"; + +export const useChatOtherUser = (otherId: number) => { + const [otherUser, setOtherUser] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const { openModal, closeModal } = useModalStore(); + + useEffect(() => { + async function fetchUser() { + try { + setIsLoading(true); + const res = await apiFetch(`/api/users/${otherId}`, { + method: "GET", + }); + setOtherUser(res); + } catch { + openModal("normal", { + message: "상대 유저 정보 조회에 실패했습니다.", + onClick: () => closeModal(), + }); + } finally { + setIsLoading(false); + } + } + + fetchUser(); + }, [otherId]); + + return { otherUser, isLoading }; +}; diff --git a/src/entities/chat/lib/useChatPost.ts b/src/entities/chat/lib/useChatPost.ts new file mode 100644 index 00000000..30cab466 --- /dev/null +++ b/src/entities/chat/lib/useChatPost.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import { PostDetail } from "@/entities/post/model/types/post"; +import { apiFetch } from "@/shared/api/fetcher"; +import { useModalStore } from "@/shared/model/modal.store"; + +export const useChatPost = (postingId: number) => { + const [post, setPost] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const { openModal, closeModal } = useModalStore(); + + useEffect(() => { + async function fetchPost() { + try { + setIsLoading(true); + const res = await apiFetch(`/api/postings/${postingId}`, { + method: "GET", + }); + setPost(res); + } catch { + openModal("normal", { + message: "게시물 정보 조회에 실패했습니다.", + onClick: () => closeModal(), + }); + } finally { + setIsLoading(false); + } + } + + fetchPost(); + }, [postingId]); + + return { post, isLoading }; +}; diff --git a/src/entities/chat/lib/useChatSocket.ts b/src/entities/chat/lib/useChatSocket.ts new file mode 100644 index 00000000..1a33cf97 --- /dev/null +++ b/src/entities/chat/lib/useChatSocket.ts @@ -0,0 +1,83 @@ +import { useEffect, useRef } from "react"; +import { ChatSocket } from "../model/socket"; +import { MessageProps } from "../model/types"; + +export const useChatSocket = ( + chatId: number | null, + setMessages: React.Dispatch>, + scrollToBottom?: () => void, +) => { + const socketRef = useRef(null); + const isConnectedRef = useRef(false); + + useEffect(() => { + if (!chatId) return; + + const socket = new ChatSocket(chatId, { + onOpen: () => { + console.log("[Socket] Connected"); + isConnectedRef.current = true; + }, + onMessage: (msg) => { + setMessages((prev) => [...prev, msg]); + scrollToBottom?.(); + }, + onSystem: (sys) => console.log("[System]", sys.message), + onClose: (code) => { + console.log("[Socket] Closed:", code); + isConnectedRef.current = false; + }, + onError: () => { + isConnectedRef.current = false; + }, + }); + + socket.connect().then(() => { + isConnectedRef.current = true; + }); + + socketRef.current = socket; + + // cleanup + return () => { + socketRef.current?.leaveRoom(); + socketRef.current = null; + isConnectedRef.current = false; + }; + }, [chatId]); + + const waitForConnection = async () => { + const socket = socketRef.current; + if (!socket) return; + + // 연결 시도 중이면 connect()의 Promise를 기다려줌 + if (!isConnectedRef.current) { + await socket.connect(); + isConnectedRef.current = true; + } + }; + + const sendMessage = async (type: "text" | "image", content: string) => { + await waitForConnection(); // 연결 안 되어있으면 여기서 기다림 + + const now = new Date(); + const sendAt = now.toISOString(); + + socketRef.current?.sendMessage(type, content); + setMessages((prev) => [ + ...prev, + { + messageId: Date.now(), + type, + content, + isMine: true, + sendAt, + isRead: true, + }, + ]); + + scrollToBottom?.(); + }; + + return { sendMessage }; +}; diff --git a/src/entities/chat/model/socket.ts b/src/entities/chat/model/socket.ts index 0492e0bc..48148638 100644 --- a/src/entities/chat/model/socket.ts +++ b/src/entities/chat/model/socket.ts @@ -95,9 +95,17 @@ export class ChatSocket { leaveRoom() { if (!this.socket) return; - console.log(this.socket); - this.socket.send(JSON.stringify({ event: "leave_room" })); - this.socket.close(1000, "User left"); + + const state = this.socket.readyState; + + // 아직 연결 중이거나 이미 닫힌 상태에서는 그냥 close()만 호출 + if (state === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ event: "leave_room" })); + this.socket.close(1000, "User left"); + } else { + this.socket.close(1000, `User left skipped - ${state.toString()}`); + } + this.socket = null; } } diff --git a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx index 079580f5..a70d01ad 100644 --- a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx +++ b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx @@ -1,16 +1,18 @@ "use client"; import React, { useState, useRef, useEffect, useMemo } from "react"; -import { User } from "@/entities/user/model/types/user"; -import { PostDetail } from "@/entities/post/model/types/post"; -import { MessageProps, MessagesResponse } from "../../model/types"; import { MessageRow, MessageRowProps } from "../MessageRow/MessageRow"; +import { uploadImage } from "@/shared/api/uploadImage"; +import { useModalStore } from "@/shared/model/modal.store"; import Button from "@/shared/ui/Button/Button"; import { TextField } from "@/shared/ui/TextField/TextField"; import DeleteIcon from "@/shared/images/delete.svg"; + import { apiFetch } from "@/shared/api/fetcher"; -import { uploadImage } from "@/shared/api/uploadImage"; -import { useModalStore } from "@/shared/model/modal.store"; -import { ChatSocket } from "../../model/socket"; +import { useChatPost } from "../../lib/useChatPost"; +import { useChatOtherUser } from "../../lib/useChatOtherUser"; +import { useChatMessages } from "../../lib/useChatMessages"; +import { useChatSocket } from "../../lib/useChatSocket"; +import { useInfiniteScroll } from "@/shared/lib/useInfiniteScroll"; export const ChattingRoom = ({ postingId, @@ -22,41 +24,32 @@ export const ChattingRoom = ({ chatId?: number; }) => { const [chatId, setChatId] = useState(initialChatId ?? null); - const [post, setPost] = useState(null); - const [otherUser, setOtherUser] = useState(null); - const [messages, setMessages] = useState([]); - const [cursor, setCursor] = useState(null); - const [hasNext, setHasNext] = useState(true); - - const [isOtherUserLoading, setIsOtherUserLoading] = useState(false); - const [isPostLoading, setIsPostLoading] = useState(false); - const [isMessagesLoading, setIsMessagesLoading] = useState(false); + const { post, isLoading: isPostLoading } = useChatPost(postingId); + const { otherUser, isLoading: isOtherUserLoading } = + useChatOtherUser(otherId); + const { + messages, + setMessages, + isLoading: isMessagesLoading, + hasNext, + fetchMoreMessages, + scrollContainerRef, + messagesEndRef, + } = useChatMessages(chatId); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + const { sendMessage } = useChatSocket(chatId, setMessages, scrollToBottom); const { openModal, closeModal } = useModalStore(); - const [text, setText] = useState(""); const [image, setImage] = useState(null); - const messagesEndRef = useRef(null); - const socketRef = useRef(null); - - const connectSocket = (id: number): Promise => { - return new Promise((resolve) => { - if (socketRef.current) socketRef.current.leaveRoom(); - - const socket = new ChatSocket(id, { - onOpen: () => { - console.log("[Socket] connected"); - resolve(); - }, - onMessage: (msg) => setMessages((prev) => [...prev, msg]), - onSystem: (sys) => console.log("[System]", sys.message), - onClose: (code) => console.log("[Socket] Closed:", code), - }); - - socket.connect(); - socketRef.current = socket; - }); - }; + const messagesTopRef = useInfiniteScroll( + fetchMoreMessages, + isMessagesLoading, + hasNext, + ); const formatTime = (time: string) => new Date(time).toLocaleTimeString("ko-KR", { @@ -70,15 +63,15 @@ export const ChattingRoom = ({ return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; }; - const messagesWithComputedProps = ( - msgList: MessageProps[], - ): MessageRowProps[] => { + const displayMessages = useMemo((): MessageRowProps[] => { + if (!messages.length) return []; + const result: MessageRowProps[] = []; let lastDate: string | null = null; - msgList.forEach((msg, i) => { - const prev = msgList[i - 1]; - const next = msgList[i + 1]; + messages.forEach((msg, i) => { + const prev = messages[i - 1]; + const next = messages[i + 1]; const currentDate = formatDate(msg.sendAt); const currentTime = formatTime(msg.sendAt); @@ -88,7 +81,6 @@ export const ChattingRoom = ({ const showTime = !next || next.isMine !== msg.isMine || nextTime !== currentTime; - //날짜가 바뀌면 날짜 구분선 추가 if (lastDate !== currentDate) { result.push({ message: { @@ -109,101 +101,15 @@ export const ChattingRoom = ({ message: msg, profileImage: !msg.isMine && showProfile - ? otherUser?.imageUrl - ? otherUser.imageUrl - : "/icons/user.svg" + ? otherUser?.imageUrl || "/icons/user.svg" : undefined, showProfile, showTime, }); }); - return result; - }; - - //게시물 정보 조회하여 렌더링 - useEffect(() => { - async function fetchPost() { - try { - setIsPostLoading(true); - const res = await apiFetch(`/api/postings/${postingId}`, { - method: "GET", - }); - - setPost(res); - } catch { - openModal("normal", { - message: "게시물 정보 조회에 실패했습니다.", - onClick: () => closeModal(), - }); - } finally { - setIsPostLoading(false); - } - } - fetchPost(); - }, [postingId]); - //상대방 정보 가져오기 - useEffect(() => { - async function fecthUser() { - try { - setIsOtherUserLoading(true); - const res = await apiFetch(`/api/users/${otherId}`, { - method: "GET", - }); - setOtherUser(res); - } catch { - openModal("normal", { - message: "상대 유저 정보 조회에 실패했습니다.", - onClick: () => closeModal(), - }); - } finally { - setIsOtherUserLoading(false); - } - } - fecthUser(); - }, []); - - useEffect(() => { - if (!chatId) return; - - async function fetchInitialMessages() { - try { - setIsMessagesLoading(true); - const res = await apiFetch( - `/api/chat/${chatId}?size=20`, - { method: "GET" }, - ); - const sorted = [...res.messages].sort( - (a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime(), - ); - setMessages(sorted); - setCursor(res.nextCursor); - setHasNext(res.hasNext); - } catch { - openModal("normal", { - message: "메시지 조회에 실패했습니다.", - onClick: () => closeModal(), - }); - } finally { - setIsMessagesLoading(false); - } - } - fetchInitialMessages(); - }, [chatId]); - - const displayMessages = useMemo( - () => messagesWithComputedProps(messages), - [messages], - ); - - //기존 채팅방이 있을 경우 바로 소켓 연결 - useEffect(() => { - if (!socketRef.current && chatId) connectSocket(chatId); - }, []); - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + return result; + }, [messages, otherUser]); const handleSend = async () => { //채팅방이 없을 시 새로운 채팅방 생성 후 소켓 연결 @@ -218,8 +124,7 @@ export const ChattingRoom = ({ body: JSON.stringify({ postingId }), }, ); - setChatId(res.chatId); - await connectSocket(res.chatId); + setChatId(res.chatId); //useChatSocket hook을 통한 소켓 자동 재연결. } catch { openModal("normal", { message: "채팅방 생성에 실패했습니다.", @@ -238,23 +143,6 @@ export const ChattingRoom = ({ setImage(null); }; - const sendMessage = (type: "text" | "image", content: string) => { - const now = new Date(); - const sendAt = now.toISOString(); - socketRef.current?.sendMessage(type, content); - setMessages((prev) => [ - ...prev, - { - messageId: Date.now(), - type, - content, - isMine: true, - sendAt, - isRead: true, - }, - ]); - }; - const handleImageChange = (e: React.ChangeEvent) => { const file = e.target.files ? e.target.files[0] : null; if (file && file.type.startsWith("image/")) { @@ -262,7 +150,7 @@ export const ChattingRoom = ({ } }; - if (isPostLoading || isOtherUserLoading || isMessagesLoading) + if (isPostLoading || isOtherUserLoading) return (

로딩 중...

@@ -289,7 +177,16 @@ export const ChattingRoom = ({
{/* 메시지 리스트 영역 */} -
+
+
+ {isMessagesLoading && ( +
+ 이전 메시지를 불러오는 중... +
+ )} {displayMessages.length === 0 ? (
메시지가 없습니다.
지금 바로 채팅을 시작해보세요!