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
26 changes: 25 additions & 1 deletion src/entities/chat/lib/useChatMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -57,6 +57,7 @@ export const useChatMessages = (chatId: number | null) => {
}, [data]);

const pushMessageToCache = (newMsg: MessageProps) => {
//메시지 리스트 업데이트
queryClient.setQueryData<InfiniteData<MessagesResponse>>(
["chatMessages", chatId],
(oldData) => {
Expand All @@ -74,6 +75,29 @@ export const useChatMessages = (chatId: number | null) => {
};
},
);

//chats list의 lastMessage 업데이트
const roles = [undefined, "buyer", "seller"];
roles.forEach((role) => {
queryClient.setQueryData<Chat[]>(["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 () => {
Expand Down
137 changes: 72 additions & 65 deletions src/entities/chat/lib/useChatSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,86 +19,87 @@ export const useChatSocket = (
}) => void,
) => {
const socketRef = useRef<ChatSocket | null>(null);
const isConnectedRef = useRef(false);
const messageQueue = useRef<QueuedMessage[]>([]); // 🔥 메시지 큐

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 };
Expand Down
4 changes: 4 additions & 0 deletions src/entities/chat/model/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export class ChatSocket {
this.events = events;
}

isOpen(): boolean {
return this.socket !== null && this.socket.readyState === WebSocket.OPEN;
}

connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.socket) {
Expand Down
15 changes: 5 additions & 10 deletions src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -32,6 +31,7 @@ export const ChattingRoom = ({
status?: DealStatus;
}) => {
const [chatId, setChatId] = useState<number | null>(initialChatId ?? null);
const queryClient = useQueryClient();
const {
data: post,
isLoading: isPostLoading,
Expand Down Expand Up @@ -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: "채팅방 생성에 실패했습니다.",
Expand Down
19 changes: 19 additions & 0 deletions src/features/chat/api/createChattingRoom.ts
Original file line number Diff line number Diff line change
@@ -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<CreateChattingRoomResponse> {
if (!postingId) throw new Error("postingId is required");

const res = await apiFetch<CreateChattingRoomResponse>(`/api/chat`, {
method: "POST",
body: JSON.stringify({ postingId }),
});
return res;
}
15 changes: 15 additions & 0 deletions src/features/chat/api/getChattingRoomStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { apiFetch } from "@/shared/api/fetcher";
export interface ChattingRoomStatus {
isExist: boolean;
chatId?: number;
}

export async function getChattingRoomStatus(
postingId: number,
): Promise<ChattingRoomStatus> {
if (!postingId) throw new Error("Invalid postingId");

return apiFetch<ChattingRoomStatus>(`/api/postings/${postingId}/chat`, {
method: "GET",
});
}
26 changes: 25 additions & 1 deletion src/widgets/postDetail/ui/PostDetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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 = () => {
Expand Down