diff --git a/src/entities/chat/lib/useChatMessages.ts b/src/entities/chat/lib/useChatMessages.ts index 368d4add..2f0ae757 100644 --- a/src/entities/chat/lib/useChatMessages.ts +++ b/src/entities/chat/lib/useChatMessages.ts @@ -5,7 +5,7 @@ import { } from "@tanstack/react-query"; import { useEffect, useRef, useMemo } from "react"; import { apiFetch } from "@/shared/api/fetcher"; -import { MessagesResponse, MessageProps } from "../model/types"; +import { MessagesResponse, MessageProps, Chat } from "../model/types"; import { useModalStore } from "@/shared/model/modal.store"; export const useChatMessages = (chatId: number | null) => { @@ -57,6 +57,7 @@ export const useChatMessages = (chatId: number | null) => { }, [data]); const pushMessageToCache = (newMsg: MessageProps) => { + //메시지 리스트 업데이트 queryClient.setQueryData>( ["chatMessages", chatId], (oldData) => { @@ -74,6 +75,29 @@ export const useChatMessages = (chatId: number | null) => { }; }, ); + + //chats list의 lastMessage 업데이트 + const roles = [undefined, "buyer", "seller"]; + roles.forEach((role) => { + queryClient.setQueryData(["chats", role], (oldChats) => { + if (!oldChats) return oldChats; + + const updatedChats = oldChats.map((chat) => + chat.chatId === chatId + ? { + ...chat, + lastMessage: newMsg, + } + : chat, + ); + + return updatedChats.sort( + (a, b) => + new Date(b.lastMessage?.sendAt ?? 0).getTime() - + new Date(a.lastMessage?.sendAt ?? 0).getTime(), + ); + }); + }); }; const fetchMoreMessages = async () => { diff --git a/src/entities/chat/lib/useChatSocket.ts b/src/entities/chat/lib/useChatSocket.ts index 6169f11b..87e00b7e 100644 --- a/src/entities/chat/lib/useChatSocket.ts +++ b/src/entities/chat/lib/useChatSocket.ts @@ -3,6 +3,12 @@ import { ChatSocket } from "../model/socket"; import { DealStatus, MessageProps } from "../model/types"; import { PostStatus } from "@/entities/post/model/types/post"; +interface QueuedMessage { + type: "text" | "image"; + content: string; + sendAt: string; +} + export const useChatSocket = ( chatId: number | null, pushMessageToCache: (msg: MessageProps) => void, @@ -13,86 +19,87 @@ export const useChatSocket = ( }) => void, ) => { const socketRef = useRef(null); - const isConnectedRef = useRef(false); + const messageQueue = useRef([]); // 🔥 메시지 큐 - useEffect(() => { - if (!chatId) return; - - const socket = new ChatSocket(chatId, { - onOpen: () => { - console.log("[Socket] Connected"); - isConnectedRef.current = true; - }, - onMessage: (msg) => { - pushMessageToCache(msg); - requestAnimationFrame(() => scrollToBottom?.()); - }, - onDealUpdate: (update) => { - onDealUpdate?.({ - postStatus: update.postStatus, - dealStatus: update.dealStatus, - }); - pushMessageToCache({ - messageId: Date.now(), - type: "system", - content: update.message, - isMine: false, - sendAt: new Date().toISOString(), - isRead: true, - }); - requestAnimationFrame(() => scrollToBottom?.()); - }, - onSystem: (sys) => console.log("[System]", sys.message), - onClose: (code) => { - console.log("[Socket] Closed:", code); - isConnectedRef.current = false; - }, - onError: () => { - isConnectedRef.current = false; - }, + /** 메시지 전송 공통 함수 (push + socket) */ + const processMessage = ( + type: "text" | "image", + content: string, + sendAt: string, + ) => { + pushMessageToCache({ + messageId: Date.now(), + type, + content, + isMine: true, + sendAt, + isRead: true, }); - socket.connect().then(() => { - isConnectedRef.current = true; + socketRef.current?.sendMessage(type, content); + + requestAnimationFrame(() => scrollToBottom?.()); + }; + + /** 큐 flush */ + const flushQueue = () => { + if (!socketRef.current || !socketRef.current.isOpen()) return; + messageQueue.current.forEach(({ type, content, sendAt }) => { + processMessage(type, content, sendAt); }); + messageQueue.current = []; + }; - socketRef.current = socket; + const connectSocket = async () => { + if (!chatId) return; + if (socketRef.current?.isOpen()) return; + + if (!socketRef.current) { + socketRef.current = new ChatSocket(chatId, { + onOpen: () => { + console.log("[Socket] Connected"); + flushQueue(); + }, + onMessage: (msg) => { + pushMessageToCache(msg); + requestAnimationFrame(() => scrollToBottom?.()); + }, + onDealUpdate: (update) => { + onDealUpdate?.(update); + pushMessageToCache({ + messageId: Date.now(), + type: "system", + content: update.message, + isMine: false, + sendAt: new Date().toISOString(), + isRead: true, + }); + requestAnimationFrame(() => scrollToBottom?.()); + }, + }); + } + + await socketRef.current.connect(); + }; + + useEffect(() => { + if (chatId) connectSocket(); - // 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 sendAt = new Date().toISOString(); - const now = new Date(); - const sendAt = now.toISOString(); - pushMessageToCache({ - messageId: Date.now(), - type, - content, - isMine: true, - sendAt, - isRead: true, - }); + if (!chatId || !socketRef.current?.isOpen()) { + messageQueue.current.push({ type, content, sendAt }); + return; + } - socketRef.current?.sendMessage(type, content); - requestAnimationFrame(() => scrollToBottom?.()); + processMessage(type, content, sendAt); }; return { sendMessage }; diff --git a/src/entities/chat/model/socket.ts b/src/entities/chat/model/socket.ts index 55e53c9f..3b1dfc6f 100644 --- a/src/entities/chat/model/socket.ts +++ b/src/entities/chat/model/socket.ts @@ -27,6 +27,10 @@ export class ChatSocket { this.events = events; } + isOpen(): boolean { + return this.socket !== null && this.socket.readyState === WebSocket.OPEN; + } + connect(): Promise { return new Promise((resolve, reject) => { if (this.socket) { diff --git a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx index 9dc302fd..d02763c0 100644 --- a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx +++ b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx @@ -8,8 +8,6 @@ import { TextField } from "@/shared/ui/TextField/TextField"; import DeleteIcon from "@/shared/icons/delete.svg"; import PostStatusBadge from "@/entities/post/ui/badge/PostStatusBadge"; import DealActionPanel from "@/features/deal/ui/DealActionPanel/DealActionPanel"; - -import { apiFetch } from "@/shared/api/fetcher"; import { useChatMessages } from "../../lib/useChatMessages"; import { useChatSocket } from "../../lib/useChatSocket"; import { useDealStatus } from "../../lib/useDealStatus"; @@ -18,7 +16,8 @@ import { DealStatus } from "../../model/types"; import { getPostDetail } from "@/entities/post/api/getPostDetail"; import { getUser } from "@/entities/user/api/getUser"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { createChattingRoom } from "@/features/chat/api/createChattingRoom"; export const ChattingRoom = ({ postingId, @@ -32,6 +31,7 @@ export const ChattingRoom = ({ status?: DealStatus; }) => { const [chatId, setChatId] = useState(initialChatId ?? null); + const queryClient = useQueryClient(); const { data: post, isLoading: isPostLoading, @@ -168,14 +168,9 @@ export const ChattingRoom = ({ if (!chatId) { try { - const res = await apiFetch<{ chatId: number; createdAt: string }>( - `/api/chat`, - { - method: "POST", - body: JSON.stringify({ postingId }), - }, - ); + const res = await createChattingRoom(postingId); setChatId(res.chatId); //useChatSocket hook을 통한 소켓 자동 재연결. + queryClient.invalidateQueries({ queryKey: ["chats"] }); } catch { openModal("normal", { message: "채팅방 생성에 실패했습니다.", diff --git a/src/features/chat/api/createChattingRoom.ts b/src/features/chat/api/createChattingRoom.ts new file mode 100644 index 00000000..2f388af2 --- /dev/null +++ b/src/features/chat/api/createChattingRoom.ts @@ -0,0 +1,19 @@ +// src/features/chat/api/createChatRoom.ts +import { apiFetch } from "@/shared/api/fetcher"; + +interface CreateChattingRoomResponse { + chatId: number; + createdAt: string; +} + +export async function createChattingRoom( + postingId: number, +): Promise { + if (!postingId) throw new Error("postingId is required"); + + const res = await apiFetch(`/api/chat`, { + method: "POST", + body: JSON.stringify({ postingId }), + }); + return res; +} diff --git a/src/features/chat/api/getChattingRoomStatus.ts b/src/features/chat/api/getChattingRoomStatus.ts new file mode 100644 index 00000000..cd47f9e1 --- /dev/null +++ b/src/features/chat/api/getChattingRoomStatus.ts @@ -0,0 +1,15 @@ +import { apiFetch } from "@/shared/api/fetcher"; +export interface ChattingRoomStatus { + isExist: boolean; + chatId?: number; +} + +export async function getChattingRoomStatus( + postingId: number, +): Promise { + if (!postingId) throw new Error("Invalid postingId"); + + return apiFetch(`/api/postings/${postingId}/chat`, { + method: "GET", + }); +} diff --git a/src/widgets/postDetail/ui/PostDetailSection.tsx b/src/widgets/postDetail/ui/PostDetailSection.tsx index 5d44440a..30b14ee3 100644 --- a/src/widgets/postDetail/ui/PostDetailSection.tsx +++ b/src/widgets/postDetail/ui/PostDetailSection.tsx @@ -15,12 +15,15 @@ import { useLike } from "@/features/like/lib/useLike"; import { usePostEditModal } from "@/features/editPost/lib/usePostEditModal"; import { useChatStore } from "@/features/chat/model/chat.store"; import { useModalStore } from "@/shared/model/modal.store"; +import { useAuthStore } from "@/features/auth/model/auth.store"; import { PostDetail } from "@/entities/post/model/types/post"; import PostStatusBadge from "@/entities/post/ui/badge/PostStatusBadge"; +import { getChattingRoomStatus } from "@/features/chat/api/getChattingRoomStatus"; export function PostDetailSection({ post }: { post: PostDetail }) { const router = useRouter(); const queryClient = useQueryClient(); + const { isLogined } = useAuthStore(); const { openModal, closeModal } = useModalStore(); const openChat = useChatStore((s) => s.mount); @@ -30,6 +33,11 @@ export function PostDetailSection({ post }: { post: PostDetail }) { enabled: !!post.sellerId, }); + const { data: chattingRoomStatus } = useQuery({ + queryKey: ["findRoom", post.postingId], + queryFn: () => getChattingRoomStatus(post.postingId), + }); + const { toggleLike, isLoading: isLikeLoading } = useLike(post.postingId); const handleToggleLike = () => toggleLike(!post.isFavorite); @@ -53,7 +61,23 @@ export function PostDetailSection({ post }: { post: PostDetail }) { }; const handleChatClick = () => { - openChat({ postingId: post.postingId, otherId: post.sellerId }); + if (!isLogined) { + openModal("normal", { + message: " 로그인이 필요한 서비스입니다.\n로그인 해주세요.", + onClick: () => { + closeModal(); + router.replace("/login"); + }, + }); + return; + } + if (chattingRoomStatus) { + openChat({ + postingId: post.postingId, + otherId: post.sellerId, + chatId: chattingRoomStatus?.chatId, + }); + } }; const handleDeleteClick = () => {