Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/entities/chat/lib/useChatMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InfiniteData<MessagesResponse>>(
Expand Down Expand Up @@ -90,6 +107,39 @@ export const useChatMessages = (chatId: number | null) => {
});
};

const applyReadStatus = () => {
if (!chatId) return;
let stopUpdating = false;

queryClient.setQueryData<InfiniteData<MessagesResponse>>(
["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;
Expand All @@ -106,7 +156,10 @@ export const useChatMessages = (chatId: number | null) => {

return {
messages,
lastReadMessageId,
lastOtherMessageId,
pushMessageToCache,
applyReadStatus,
fetchMoreMessages,
hasNextPage,
isMessagesFirstLoading,
Expand Down
58 changes: 32 additions & 26 deletions src/entities/chat/lib/useChatSocket.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,70 @@
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";

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<ChatSocket | null>(null);
const messageQueue = useRef<QueuedMessage[]>([]); // 🔥 메시지 큐

/** 메시지 전송 공통 함수 (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);
Expand All @@ -76,6 +78,11 @@ export const useChatSocket = (
});
requestAnimationFrame(() => scrollToBottom?.());
},
onRead: onMessageRead,
onClose: () => {
console.log("[Socket] disconnected");
setIsSocketConncted(false);
},
});
}

Expand All @@ -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 };
};
26 changes: 23 additions & 3 deletions src/entities/chat/model/chatSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ export interface ChatSocketEvents extends SocketEvents {
dealStatus: DealStatus;
message: string;
}) => void;
onRead?: (messageId: number) => void;
}

export class ChatSocket extends Socket<ChatSocketEvents> {
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 {
Expand All @@ -42,7 +45,13 @@ export class ChatSocket extends Socket<ChatSocketEvents> {
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;
}
Expand All @@ -53,7 +62,7 @@ export class ChatSocket extends Socket<ChatSocketEvents> {
messageId: data.messageId,
type: data.type,
content: data.content,
isMine: false,
isMine: data.senderId !== this.otherId,
sendAt: data.createdAt,
isRead: false,
};
Expand All @@ -73,4 +82,15 @@ export class ChatSocket extends Socket<ChatSocketEvents> {
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));
}
}
1 change: 1 addition & 0 deletions src/entities/chat/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ export interface MessagesResponse {
messages: MessageProps[];
hasNext: boolean;
nextCursor: number | null;
lastReadMessageId: number;
}
24 changes: 23 additions & 1 deletion src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ export const ChattingRoom = ({

const {
messages,
lastReadMessageId,
lastOtherMessageId,
pushMessageToCache,
applyReadStatus,
fetchMoreMessages,
hasNextPage,
isError: isChatMessagesError,
Expand Down Expand Up @@ -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<File | null>(null);
Expand Down
3 changes: 3 additions & 0 deletions src/entities/chat/ui/Message/Message.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const meta: Meta<typeof Message> = {
},
content: { control: "text" },
isMine: { control: "boolean" },
isRead: { contorl: "boolean" },
},
};

Expand All @@ -26,6 +27,7 @@ export const MyText: Story = {
type: "text",
content: "네, 아직 있습니다. 상태는 아주 좋아요 🙂",
isMine: true,
isRead: true,
},
};

Expand All @@ -34,6 +36,7 @@ export const OtherText: Story = {
type: "text",
content: "안녕하세요! 이 물건 아직 있나요?",
isMine: false,
isRead: false,
},
};

Expand Down
9 changes: 9 additions & 0 deletions src/entities/chat/ui/MessageRow/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ export const MessageRow = ({
)}

<Message {...message} />
{message.isMine && !message.isRead && (
<span
className={`absolute -left-4 text-[11px] font-bold text-blue-400 ${
showTime ? "bottom-4" : "bottom-0"
}`}
>
1
</span>
)}

{showTime && (
<span
Expand Down