diff --git a/src/entities/chat/lib/useChatMessages.ts b/src/entities/chat/lib/useChatMessages.ts index 374c8cd8..12e81b47 100644 --- a/src/entities/chat/lib/useChatMessages.ts +++ b/src/entities/chat/lib/useChatMessages.ts @@ -46,6 +46,23 @@ export const useChatMessages = (chatId: number | null) => { .flatMap((page) => [...page.messages].reverse()); }, [data]); + const lastReadMessageId = useMemo(() => { + if (!data || data.pages.length == 0) return null; + return data.pages[0].lastReadMessageId; + }, [data]); + + const lastOtherMessageId: number | null = useMemo(() => { + if (!messages.length) return null; + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.type !== "system" && !msg.isMine) { + return msg.messageId; + } + } + return null; + }, [messages]); + const pushMessageToCache = (newMsg: MessageProps) => { //메시지 리스트 업데이트 queryClient.setQueryData>( @@ -90,6 +107,39 @@ export const useChatMessages = (chatId: number | null) => { }); }; + const applyReadStatus = () => { + if (!chatId) return; + let stopUpdating = false; + + queryClient.setQueryData>( + ["chatMessages", chatId], + (oldData) => { + if (!oldData) return oldData; + + const updatedPages = oldData.pages.map((page) => ({ + ...page, + messages: page.messages.map((msg) => { + if (stopUpdating) return msg; + if (msg.isMine) { + if (!msg.isRead) { + return { ...msg, isRead: true }; + } else { + stopUpdating = true; + return msg; + } + } + return msg; + }), + })); + + return { + pageParams: oldData.pageParams, + pages: updatedPages, + }; + }, + ); + }; + const fetchMoreMessages = async () => { const container = scrollContainerRef.current; const prevHeight = container?.scrollHeight ?? 0; @@ -106,7 +156,10 @@ export const useChatMessages = (chatId: number | null) => { return { messages, + lastReadMessageId, + lastOtherMessageId, pushMessageToCache, + applyReadStatus, fetchMoreMessages, hasNextPage, isMessagesFirstLoading, diff --git a/src/entities/chat/lib/useChatSocket.ts b/src/entities/chat/lib/useChatSocket.ts index 2e6e14b5..f905870a 100644 --- a/src/entities/chat/lib/useChatSocket.ts +++ b/src/entities/chat/lib/useChatSocket.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useState, useEffect, useRef } from "react"; import { ChatSocket } from "../model/chatSocket"; import { DealStatus, MessageProps } from "../model/types"; import { PostStatus } from "@/entities/post/model/types/post"; @@ -6,63 +6,65 @@ import { PostStatus } from "@/entities/post/model/types/post"; interface QueuedMessage { type: "text" | "image"; content: string; - sendAt: string; } export const useChatSocket = ( chatId: number | null, + otherId: number | null, pushMessageToCache: (msg: MessageProps) => void, scrollToBottom?: () => void, onDealUpdate?: (update: { postStatus: PostStatus; dealStatus: DealStatus; }) => void, + getLastOtherMessageId?: () => number | null, + onMessageRead?: (messageId: number) => void, ) => { + const [isSocketConnected, setIsSocketConncted] = useState(false); const socketRef = useRef(null); const messageQueue = useRef([]); // 🔥 메시지 큐 /** 메시지 전송 공통 함수 (push + socket) */ - const processMessage = ( - type: "text" | "image", - content: string, - sendAt: string, - ) => { - pushMessageToCache({ - messageId: Date.now(), - type, - content, - isMine: true, - sendAt, - isRead: true, - }); - + const processMessage = (type: "text" | "image", content: string) => { 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.forEach(({ type, content }) => { + processMessage(type, content); }); messageQueue.current = []; }; + //현재 화면 상대 마지막 메시지를 서버에 읽음 처리 요청 + const readLastMessage = () => { + const lastId = getLastOtherMessageId?.(); + if (lastId && socketRef.current?.isOpen()) { + socketRef.current.readMessage(lastId); + console.log(`[Socket] Send read_message for ID: ${lastId}`); + } + }; + const connectSocket = async () => { - if (!chatId) return; + if (!chatId || !otherId) return; if (socketRef.current?.isOpen()) return; if (!socketRef.current) { - socketRef.current = new ChatSocket(chatId, { + socketRef.current = new ChatSocket(chatId, otherId, { onOpen: () => { + setIsSocketConncted(true); console.log("[Socket] Connected"); flushQueue(); }, onMessage: (msg) => { pushMessageToCache(msg); requestAnimationFrame(() => scrollToBottom?.()); + if (!msg.isMine) { + readLastMessage(); + } }, onDealUpdate: (update) => { onDealUpdate?.(update); @@ -76,6 +78,11 @@ export const useChatSocket = ( }); requestAnimationFrame(() => scrollToBottom?.()); }, + onRead: onMessageRead, + onClose: () => { + console.log("[Socket] disconnected"); + setIsSocketConncted(false); + }, }); } @@ -88,19 +95,18 @@ export const useChatSocket = ( return () => { socketRef.current?.close(); socketRef.current = null; + setIsSocketConncted(false); }; }, [chatId]); const sendMessage = async (type: "text" | "image", content: string) => { - const sendAt = new Date().toISOString(); - if (!chatId || !socketRef.current?.isOpen()) { - messageQueue.current.push({ type, content, sendAt }); + messageQueue.current.push({ type, content }); return; } - processMessage(type, content, sendAt); + processMessage(type, content); }; - return { sendMessage }; + return { isSocketConnected, sendMessage, readLastMessage }; }; diff --git a/src/entities/chat/model/chatSocket.ts b/src/entities/chat/model/chatSocket.ts index 453bb842..b7db4d2c 100644 --- a/src/entities/chat/model/chatSocket.ts +++ b/src/entities/chat/model/chatSocket.ts @@ -10,14 +10,17 @@ export interface ChatSocketEvents extends SocketEvents { dealStatus: DealStatus; message: string; }) => void; + onRead?: (messageId: number) => void; } export class ChatSocket extends Socket { private chatId: number; + private otherId: number; - constructor(chatId: number, events: ChatSocketEvents = {}) { + constructor(chatId: number, otherId: number, events: ChatSocketEvents = {}) { super(events); this.chatId = chatId; + this.otherId = otherId; } protected getEndpointPath(): string { @@ -42,7 +45,13 @@ export class ChatSocket extends Socket { return; } - if (["welcome", "system", "read"].includes(data.type)) { + if (data.type === "read") { + console.log(`[Socket] : 읽음 처리 이벤트 수신`); + this.events.onRead?.(data.lastReadMessageId); + return; + } + + if (["welcome", "system"].includes(data.type)) { this.events.onSystem?.(data); return; } @@ -53,7 +62,7 @@ export class ChatSocket extends Socket { messageId: data.messageId, type: data.type, content: data.content, - isMine: false, + isMine: data.senderId !== this.otherId, sendAt: data.createdAt, isRead: false, }; @@ -73,4 +82,15 @@ export class ChatSocket extends Socket { const payload = { event: "send_message", type, content }; this.socket.send(JSON.stringify(payload)); } + + public readMessage(messageId: number) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + console.warn("[ChatSocket] Not connected"); + return; + } + + console.log(`[Socket] : read_message 이벤트 송신 messageId : ${messageId}`); + const payload = { event: "read_message", messageId }; + this.socket.send(JSON.stringify(payload)); + } } diff --git a/src/entities/chat/model/types.ts b/src/entities/chat/model/types.ts index 8dacc277..ac30ef4c 100644 --- a/src/entities/chat/model/types.ts +++ b/src/entities/chat/model/types.ts @@ -26,4 +26,5 @@ export interface MessagesResponse { messages: MessageProps[]; hasNext: boolean; nextCursor: number | null; + lastReadMessageId: number; } diff --git a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx index b48f0106..2c273180 100644 --- a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx +++ b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx @@ -63,7 +63,10 @@ export const ChattingRoom = ({ const { messages, + lastReadMessageId, + lastOtherMessageId, pushMessageToCache, + applyReadStatus, fetchMoreMessages, hasNextPage, isError: isChatMessagesError, @@ -102,13 +105,32 @@ export const ChattingRoom = ({ } }, [messages]); - const { sendMessage } = useChatSocket( + const { isSocketConnected, sendMessage, readLastMessage } = useChatSocket( chatId, + otherId, pushMessageToCache, scrollToBottom, applyUpdate, + () => lastOtherMessageId, + applyReadStatus, ); + const firstReadRequestDone = useRef(false); + useEffect(() => { + if (firstReadRequestDone.current) { + return; + } + if ( + !isMessagesLoading && + isSocketConnected && + messages.length > 0 && + lastOtherMessageId !== null + ) { + readLastMessage(); + firstReadRequestDone.current = true; + } + }, [messages, isSocketConnected]); + const { openModal, closeModal } = useModalStore(); const [text, setText] = useState(""); const [image, setImage] = useState(null); diff --git a/src/entities/chat/ui/Message/Message.stories.tsx b/src/entities/chat/ui/Message/Message.stories.tsx index 4279d110..87d06e5c 100644 --- a/src/entities/chat/ui/Message/Message.stories.tsx +++ b/src/entities/chat/ui/Message/Message.stories.tsx @@ -15,6 +15,7 @@ const meta: Meta = { }, content: { control: "text" }, isMine: { control: "boolean" }, + isRead: { contorl: "boolean" }, }, }; @@ -26,6 +27,7 @@ export const MyText: Story = { type: "text", content: "네, 아직 있습니다. 상태는 아주 좋아요 🙂", isMine: true, + isRead: true, }, }; @@ -34,6 +36,7 @@ export const OtherText: Story = { type: "text", content: "안녕하세요! 이 물건 아직 있나요?", isMine: false, + isRead: false, }, }; diff --git a/src/entities/chat/ui/MessageRow/MessageRow.tsx b/src/entities/chat/ui/MessageRow/MessageRow.tsx index 6e0938e3..a97fc3f9 100644 --- a/src/entities/chat/ui/MessageRow/MessageRow.tsx +++ b/src/entities/chat/ui/MessageRow/MessageRow.tsx @@ -46,6 +46,15 @@ export const MessageRow = ({ )} + {message.isMine && !message.isRead && ( + + 1 + + )} {showTime && (