diff --git a/src/entities/chat/lib/useChatMessages.ts b/src/entities/chat/lib/useChatMessages.ts index b113ffeb..368d4add 100644 --- a/src/entities/chat/lib/useChatMessages.ts +++ b/src/entities/chat/lib/useChatMessages.ts @@ -1,89 +1,103 @@ -import { useEffect, useState, useRef } from "react"; +import { + InfiniteData, + useInfiniteQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { useEffect, useRef, useMemo } 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 queryClient = useQueryClient(); const scrollContainerRef = useRef(null); const messagesEndRef = useRef(null); + const { openModal, closeModal } = useModalStore(); - // 초기 메시지 - useEffect(() => { - if (!chatId) return; + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage: isMessagesLoading, + isLoading: isMessagesFirstLoading, + } = useInfiniteQuery({ + queryKey: ["chatMessages", chatId], + enabled: !!chatId, + initialPageParam: null as number | null, - async function fetchMessages() { - try { - setIsLoading(true); - const res = await apiFetch( - `/api/chat/${chatId}?size=20`, - { method: "GET" }, - ); + queryFn: async ({ pageParam }) => { + const url = pageParam + ? `/api/chat/${chatId}?cursor=${pageParam}&size=20` + : `/api/chat/${chatId}?size=20`; - setMessages(res.messages.reverse()); - setCursor(res.nextCursor); - setHasNext(res.hasNext); + return apiFetch(url, { method: "GET" }); + }, - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "auto" }); - } - } catch { - openModal("normal", { - message: "메시지 조회에 실패했습니다.", - onClick: () => closeModal(), - }); - } finally { - setIsLoading(false); - } + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.nextCursor : undefined, + }); + + useEffect(() => { + if (error) { + openModal("normal", { + message: "메시지 로딩 중 에러가 발생했습니다.", + onClick: closeModal, + }); } + }, [error]); - fetchMessages(); - }, [chatId]); + const messages: MessageProps[] = useMemo(() => { + if (!data) return []; - // 이전 메시지 추가 로드 - const fetchMoreMessages = async () => { - if (!chatId || !hasNext || isLoading) return; + return data.pages + .slice() + .reverse() + .flatMap((page) => [...page.messages].reverse()); + }, [data]); + + const pushMessageToCache = (newMsg: MessageProps) => { + queryClient.setQueryData>( + ["chatMessages", chatId], + (oldData) => { + if (!oldData) return oldData; + const firstPage = oldData.pages[0]; + + const updatedFirstPage: MessagesResponse = { + ...firstPage, + messages: [newMsg, ...firstPage.messages], + }; + + return { + pageParams: oldData.pageParams, + pages: [updatedFirstPage, ...oldData.pages.slice(1)], + }; + }, + ); + }; + const fetchMoreMessages = async () => { 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(), + await fetchNextPage(); + + if (container) { + requestAnimationFrame(() => { + const newHeight = container.scrollHeight; + container.scrollTop = newHeight - prevHeight; }); - } finally { - setIsLoading(false); } }; return { messages, - setMessages, - isLoading, - hasNext, + pushMessageToCache, fetchMoreMessages, + hasNextPage, + isMessagesFirstLoading, + isMessagesLoading, + error, scrollContainerRef, messagesEndRef, }; diff --git a/src/entities/chat/lib/useChatSocket.ts b/src/entities/chat/lib/useChatSocket.ts index 1a33cf97..f5c38b98 100644 --- a/src/entities/chat/lib/useChatSocket.ts +++ b/src/entities/chat/lib/useChatSocket.ts @@ -4,7 +4,7 @@ import { MessageProps } from "../model/types"; export const useChatSocket = ( chatId: number | null, - setMessages: React.Dispatch>, + pushMessageToCache: (msg: MessageProps) => void, scrollToBottom?: () => void, ) => { const socketRef = useRef(null); @@ -19,8 +19,8 @@ export const useChatSocket = ( isConnectedRef.current = true; }, onMessage: (msg) => { - setMessages((prev) => [...prev, msg]); - scrollToBottom?.(); + pushMessageToCache(msg); + requestAnimationFrame(() => scrollToBottom?.()); }, onSystem: (sys) => console.log("[System]", sys.message), onClose: (code) => { @@ -62,21 +62,17 @@ export const useChatSocket = ( const now = new Date(); const sendAt = now.toISOString(); + pushMessageToCache({ + messageId: Date.now(), + type, + content, + isMine: true, + sendAt, + isRead: true, + }); socketRef.current?.sendMessage(type, content); - setMessages((prev) => [ - ...prev, - { - messageId: Date.now(), - type, - content, - isMine: true, - sendAt, - isRead: true, - }, - ]); - - scrollToBottom?.(); + requestAnimationFrame(() => scrollToBottom?.()); }; return { sendMessage }; diff --git a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx index a70d01ad..20fc8ea1 100644 --- a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx +++ b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx @@ -8,12 +8,14 @@ import { TextField } from "@/shared/ui/TextField/TextField"; import DeleteIcon from "@/shared/images/delete.svg"; import { apiFetch } from "@/shared/api/fetcher"; -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"; +import { getPostDetail } from "@/entities/post/api/getPostDetail"; +import { getUser } from "@/entities/user/api/getUser"; +import { useQuery } from "@tanstack/react-query"; + export const ChattingRoom = ({ postingId, otherId, @@ -24,15 +26,31 @@ export const ChattingRoom = ({ chatId?: number; }) => { const [chatId, setChatId] = useState(initialChatId ?? null); - const { post, isLoading: isPostLoading } = useChatPost(postingId); - const { otherUser, isLoading: isOtherUserLoading } = - useChatOtherUser(otherId); + const { + data: post, + isLoading: isPostLoading, + isError: isPostingError, + } = useQuery({ + queryKey: ["postDetail", postingId], + queryFn: () => getPostDetail(postingId), + }); + + const { + data: otherUser, + isLoading: isOtherUserLoading, + isError: isOtherUserError, + } = useQuery({ + queryKey: ["otherUser", otherId], + queryFn: () => getUser(otherId), + }); + const { messages, - setMessages, - isLoading: isMessagesLoading, - hasNext, + pushMessageToCache, fetchMoreMessages, + hasNextPage, + isMessagesFirstLoading, + isMessagesLoading, scrollContainerRef, messagesEndRef, } = useChatMessages(chatId); @@ -40,7 +58,21 @@ export const ChattingRoom = ({ const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; - const { sendMessage } = useChatSocket(chatId, setMessages, scrollToBottom); + + const initialScrollDone = useRef(false); + + useEffect(() => { + if (!initialScrollDone.current && messages.length > 0) { + initialScrollDone.current = true; + messagesEndRef.current?.scrollIntoView({ behavior: "auto" }); + } + }, [messages]); + + const { sendMessage } = useChatSocket( + chatId, + pushMessageToCache, + scrollToBottom, + ); const { openModal, closeModal } = useModalStore(); const [text, setText] = useState(""); @@ -48,7 +80,7 @@ export const ChattingRoom = ({ const messagesTopRef = useInfiniteScroll( fetchMoreMessages, isMessagesLoading, - hasNext, + hasNextPage, ); const formatTime = (time: string) =>