Skip to content

Commit 19d4d8e

Browse files
authored
Merge pull request #90 from Leets-Official/87-feat/채팅-API-연결
[Feat] 채팅 API 연결
2 parents 4067de2 + 4a75029 commit 19d4d8e

31 files changed

Lines changed: 633 additions & 451 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
},
2222
"dependencies": {
2323
"@hookform/resolvers": "^5.2.2",
24+
"@stomp/stompjs": "^7.3.0",
2425
"@tanstack/react-query": "^5.90.12",
2526
"@tanstack/react-query-devtools": "^5.91.1",
2627
"@vercel/react-router": "^1.2.4",

src/app/layout/ProtectedLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const ProtectedLayout = () => {
1010
}
1111

1212
if (!accessToken) {
13-
return <Navigate to={ROUTES.LOGIN} replace />;
13+
return <Navigate to={ROUTES.MAIN} replace />;
1414
}
1515

1616
return <Outlet />;

src/app/styles/index.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@
101101
}
102102

103103
@layer base {
104+
:root {
105+
--scrollbar-width: 0px;
106+
}
107+
104108
html {
105109
font-family: var(--font-sans);
106110
zoom: 0.9;

src/pages/chat/model/useChatState.ts

Lines changed: 130 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,133 +1,169 @@
1-
import { CHAT_THREADS, INITIAL_STATUS_BY_THREAD, THREAD_CONTENT } from '@shared/mocks/data/chat';
2-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3-
import type { ThreadContent } from '@shared/types/chat';
4-
5-
type StatusOption = (typeof INITIAL_STATUS_BY_THREAD)[string];
6-
7-
const formatMetaLabel = (date: Date) => {
8-
const hours = date.getHours();
9-
const minutes = date.getMinutes();
10-
const meridiem = hours >= 12 ? '오후' : '오전';
11-
const hourLabel = `${hours % 12 === 0 ? 12 : hours % 12}`.padStart(2, '0');
12-
const minuteLabel = `${minutes}`.padStart(2, '0');
13-
return `읽음 · ${meridiem} ${hourLabel}:${minuteLabel}`;
14-
};
1+
import {
2+
type ChatMessageItem,
3+
type ChatRoomData,
4+
type PostStatus,
5+
type WebSocketMessage,
6+
chatSocket,
7+
isReadNotification,
8+
useChatMessagesQuery,
9+
useChatRoomsQuery,
10+
useCreateRoomMutation,
11+
} from '@shared/apis/chat';
12+
import { useAuthStore } from '@shared/stores';
13+
import { useCallback, useEffect, useRef, useState } from 'react';
1514

1615
export const useChatState = () => {
17-
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
18-
const [message, setMessage] = useState('');
19-
const [unreadByThread, setUnreadByThread] = useState<Record<string, boolean>>(() =>
20-
CHAT_THREADS.reduce<Record<string, boolean>>((acc, thread) => {
21-
if (thread.hasAlert) {
22-
acc[thread.id] = true;
23-
}
24-
return acc;
25-
}, {})
26-
);
27-
const [threadContent, setThreadContent] = useState<Record<string, ThreadContent>>(THREAD_CONTENT);
28-
const [statusByThread, setStatusByThread] = useState<Record<string, StatusOption>>(INITIAL_STATUS_BY_THREAD);
16+
const { userId } = useAuthStore();
17+
const [selectedRoomId, setSelectedRoomId] = useState<number | null>(null);
18+
const [currentRoom, setCurrentRoom] = useState<ChatRoomData | null>(null);
19+
const [localMessages, setLocalMessages] = useState<ChatMessageItem[]>([]);
20+
const [statusByRoom, setStatusByRoom] = useState<Record<number, PostStatus>>({});
2921
const messageListRef = useRef<HTMLDivElement | null>(null);
3022

31-
const hasSelection = selectedThreadId !== null;
32-
const activeThread = useMemo(
33-
() => (selectedThreadId ? threadContent[selectedThreadId] : null),
34-
[selectedThreadId, threadContent]
35-
);
23+
const { data: rooms = [], refetch: refetchRooms } = useChatRoomsQuery();
24+
const { data: serverMessages = [] } = useChatMessagesQuery(selectedRoomId);
25+
const { mutate: createRoom } = useCreateRoomMutation();
3626

37-
const markThreadReadIfNeeded = useCallback(
38-
(threadId: string | null) => {
39-
if (!threadId) {
40-
return;
41-
}
42-
if (!unreadByThread[threadId]) {
43-
return;
27+
const messages = [
28+
...serverMessages,
29+
...localMessages.filter((m) => !serverMessages.some((s) => s.messageId === m.messageId)),
30+
];
31+
32+
const hasSelection = selectedRoomId !== null;
33+
const activeStatus = selectedRoomId
34+
? (statusByRoom[selectedRoomId] ?? currentRoom?.postStatus ?? '판매중')
35+
: '판매중';
36+
37+
const handleWebSocketMessage = useCallback(
38+
(message: WebSocketMessage) => {
39+
if (isReadNotification(message)) {
40+
setLocalMessages((prev) => prev.map((m) => (m.roomId === message.roomId ? { ...m, read: true } : m)));
41+
} else {
42+
const newMessage: ChatMessageItem = {
43+
messageId: message.messageId,
44+
roomId: message.roomId,
45+
sellPostId: message.sellPostId,
46+
senderId: message.senderId,
47+
senderNickname: message.senderNickname,
48+
content: message.content,
49+
type: message.type,
50+
sendTime: message.sendTime,
51+
read: message.isRead,
52+
};
53+
setLocalMessages((prev) => [...prev, newMessage]);
54+
refetchRooms();
4455
}
45-
setUnreadByThread((prev) => ({ ...prev, [threadId]: false }));
4656
},
47-
[unreadByThread]
57+
[refetchRooms]
4858
);
4959

50-
const handleSend = (nextMessage: string) => {
51-
if (!selectedThreadId || !nextMessage.trim()) {
52-
setMessage('');
60+
// WebSocket 연결
61+
useEffect(() => {
62+
if (!userId) {
63+
return;
64+
}
65+
66+
chatSocket.connect(
67+
() => {
68+
if (selectedRoomId) {
69+
chatSocket.subscribeRoom(selectedRoomId, handleWebSocketMessage);
70+
}
71+
},
72+
(error) => {
73+
console.error('WebSocket error:', error);
74+
}
75+
);
76+
77+
return () => {
78+
chatSocket.disconnect();
79+
};
80+
}, [userId, selectedRoomId, handleWebSocketMessage]);
81+
82+
// 방 선택 시 구독 변경
83+
useEffect(() => {
84+
if (selectedRoomId && chatSocket.isConnected) {
85+
setLocalMessages([]);
86+
chatSocket.subscribeRoom(selectedRoomId, handleWebSocketMessage);
87+
chatSocket.sendRead({ roomId: selectedRoomId });
88+
}
89+
}, [selectedRoomId, handleWebSocketMessage]);
90+
91+
// 방 선택 시 상세 정보 조회
92+
useEffect(() => {
93+
if (!selectedRoomId || !userId) {
5394
return;
5495
}
5596

56-
const now = new Date();
57-
const metaLabel = formatMetaLabel(now);
97+
const room = rooms.find((r) => r.roomId === selectedRoomId);
98+
if (room) {
99+
createRoom(
100+
{ sellerId: room.partnerId, buyerId: userId, sellPostId: 0 },
101+
{
102+
onSuccess: (data) => {
103+
setCurrentRoom(data);
104+
setStatusByRoom((prev) => ({ ...prev, [selectedRoomId]: data.postStatus }));
105+
},
106+
}
107+
);
108+
}
109+
}, [selectedRoomId, userId, rooms, createRoom]);
58110

59-
setThreadContent((prev) => {
60-
const current = prev[selectedThreadId];
61-
if (!current) {
62-
return prev;
111+
const handleSend = useCallback(
112+
(content: string) => {
113+
if (!selectedRoomId || !content.trim()) {
114+
return;
63115
}
64-
return {
65-
...prev,
66-
[selectedThreadId]: {
67-
...current,
68-
timeline: [
69-
...current.timeline,
70-
{
71-
type: 'message',
72-
id: `local-${Date.now()}`,
73-
role: 'sender',
74-
message: nextMessage.trim(),
75-
meta: metaLabel,
76-
metaDateTime: now.toISOString(),
77-
},
78-
],
79-
},
80-
};
81-
});
82-
setMessage('');
83-
};
84116

85-
const scrollToBottom = () => {
117+
chatSocket.sendMessage({
118+
roomId: selectedRoomId,
119+
content: content.trim(),
120+
type: 'TEXT',
121+
});
122+
},
123+
[selectedRoomId]
124+
);
125+
126+
const scrollToBottom = useCallback(() => {
86127
const container = messageListRef.current;
87-
if (!container) {
88-
return;
128+
if (container) {
129+
container.scrollTop = container.scrollHeight;
89130
}
90-
container.scrollTop = container.scrollHeight;
91-
};
131+
}, []);
92132

93133
const handleScroll = useCallback(() => {
94-
if (!selectedThreadId) {
134+
if (!selectedRoomId) {
95135
return;
96136
}
137+
97138
const container = messageListRef.current;
98139
if (!container) {
99140
return;
100141
}
142+
101143
const isAtBottom =
102144
container.scrollHeight <= container.clientHeight + 1 ||
103145
container.scrollTop + container.clientHeight >= container.scrollHeight - 2;
146+
104147
if (isAtBottom) {
105-
markThreadReadIfNeeded(selectedThreadId);
148+
chatSocket.sendRead({ roomId: selectedRoomId });
106149
}
107-
}, [selectedThreadId, markThreadReadIfNeeded]);
150+
}, [selectedRoomId]);
108151

109152
useEffect(() => {
110-
if (!selectedThreadId) {
111-
return;
112-
}
113-
const handle = window.requestAnimationFrame(() => {
114-
scrollToBottom();
115-
handleScroll();
116-
});
117-
return () => window.cancelAnimationFrame(handle);
118-
}, [selectedThreadId, activeThread?.timeline.length, handleScroll]);
153+
scrollToBottom();
154+
}, [messages.length, scrollToBottom]);
119155

120156
return {
121-
activeThread,
157+
rooms,
158+
selectedRoomId,
159+
setSelectedRoomId,
160+
currentRoom,
161+
messages,
162+
currentUserId: userId ?? 0,
122163
hasSelection,
123-
message,
124164
messageListRef,
125-
selectedThreadId,
126-
setMessage,
127-
setSelectedThreadId,
128-
unreadByThread,
129-
statusByThread,
130-
setStatusByThread,
165+
activeStatus,
166+
setStatusByRoom,
131167
handleSend,
132168
handleScroll,
133169
};

src/pages/chat/ui/ChatConversation.tsx

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,40 @@
1-
import { type STATUS_OPTIONS } from '@shared/mocks/data/chat';
21
import { ChatBubble } from '@shared/ui/ChatBubble';
32
import { ChatInput } from '@shared/ui/ChatInput';
43
import { type RefObject } from 'react';
54
import { ChatConversationHeader } from './ChatConversationHeader';
65
import { ChatStatusDropdown } from './ChatStatusDropdown';
7-
import type { ThreadContent } from '@shared/types/chat';
6+
import type { ChatMessageItem, ChatRoomData, PostStatus } from '@shared/apis/chat';
87

98
type ChatConversationProps = {
10-
activeThread: ThreadContent | null;
9+
room: ChatRoomData | null;
10+
messages: ChatMessageItem[];
11+
currentUserId: number;
1112
hasSelection: boolean;
12-
message: string;
1313
messageListRef: RefObject<HTMLDivElement | null>;
1414
onSend: (message: string) => void;
15-
onMessageChange: (value: string) => void;
1615
onScroll: () => void;
17-
activeStatus: (typeof STATUS_OPTIONS)[number];
18-
onStatusChange: (value: (typeof STATUS_OPTIONS)[number]) => void;
16+
activeStatus: PostStatus;
17+
onStatusChange: (value: PostStatus) => void;
18+
};
19+
20+
const formatMeta = (sendTime: string, isRead: boolean, isSender: boolean) => {
21+
const date = new Date(sendTime);
22+
const hours = date.getHours();
23+
const minutes = date.getMinutes();
24+
const meridiem = hours >= 12 ? '오후' : '오전';
25+
const hourLabel = `${hours % 12 === 0 ? 12 : hours % 12}`.padStart(2, '0');
26+
const minuteLabel = `${minutes}`.padStart(2, '0');
27+
const timeStr = `${meridiem} ${hourLabel}:${minuteLabel}`;
28+
return isSender && isRead ? `읽음 · ${timeStr}` : timeStr;
1929
};
2030

2131
export const ChatConversation = ({
22-
activeThread,
32+
room,
33+
messages,
34+
currentUserId,
2335
hasSelection,
24-
message,
2536
messageListRef,
2637
onSend,
27-
onMessageChange,
2838
onScroll,
2939
activeStatus,
3040
onStatusChange,
@@ -33,44 +43,36 @@ export const ChatConversation = ({
3343
<section className="flex w-full flex-1 flex-col rounded-[24px] bg-gray-50 px-[22px] py-[22px] xl:h-[932px] xl:w-[690px] xl:max-w-[690px] xl:shrink-0">
3444
{!hasSelection ? (
3545
<div className="flex flex-1 items-center justify-center text-gray-400">대화방을 선택해 주세요.</div>
36-
) : !activeThread ? (
37-
<div className="flex flex-1 items-center justify-center text-gray-400">아직 대화 기록이 없습니다.</div>
46+
) : !room ? (
47+
<div className="flex flex-1 items-center justify-center text-gray-400">채팅방 정보를 불러오는 중...</div>
3848
) : (
3949
<>
4050
<ChatConversationHeader
41-
thread={activeThread}
51+
room={room}
4252
statusDropdown={<ChatStatusDropdown activeStatus={activeStatus} onStatusChange={onStatusChange} />}
4353
/>
4454

4555
<div
46-
className="mt-[28px] flex min-h-0 flex-1 flex-col gap-[24px] overflow-y-auto pr-2"
56+
className="mt-xxl gap-xl flex min-h-0 flex-1 flex-col overflow-y-auto pr-2"
4757
ref={messageListRef}
4858
onScroll={onScroll}
4959
>
50-
{activeThread.timeline.map((item) => {
51-
if (item.type === 'date') {
52-
return (
53-
<div key={item.id} className="flex justify-center">
54-
<span className="typo-caption-2 rounded-full bg-white px-4 py-2 text-gray-400">{item.label}</span>
55-
</div>
56-
);
57-
}
58-
59-
const isSender = item.role === 'sender';
60+
{messages.map((msg) => {
61+
const isSender = msg.senderId === currentUserId;
6062
return (
6163
<ChatBubble
62-
key={item.id}
64+
key={msg.messageId}
6365
variant={isSender ? 'sender' : 'receiver'}
64-
message={item.message}
65-
meta={item.meta}
66-
metaDateTime={item.metaDateTime}
66+
message={msg.content}
67+
meta={formatMeta(msg.sendTime, msg.read, isSender)}
68+
metaDateTime={msg.sendTime}
6769
/>
6870
);
6971
})}
7072
</div>
7173

72-
<div className="mt-[24px] w-full max-w-[647px]">
73-
<ChatInput placeholder="메시지를 입력하세요." value={message} onChange={onMessageChange} onSend={onSend} />
74+
<div className="mt-xl w-full max-w-[647px]">
75+
<ChatInput placeholder="메시지를 입력하세요." onSend={onSend} />
7476
</div>
7577
</>
7678
)}

0 commit comments

Comments
 (0)