From 057d18c004b2fc8e1ab1cc33cfdf0a9867093662 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Mon, 30 Dec 2024 09:24:57 +0900 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=93=A6[Chore]=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=84=A4=EC=B9=98=20#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 99 ++++++++++++++++++++++++++++++++++++++++++++--- package.json | 3 ++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8a3a068..f6efef56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "@headlessui/react": "^2.2.0", "@hookform/resolvers": "^3.9.1", "@lukemorales/query-key-factory": "^1.3.4", + "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.61.3", "@tanstack/react-query-devtools": "^5.61.3", "@types/react-datepicker": "^6.2.0", + "@types/sockjs-client": "^1.5.4", "axios": "^1.7.8", "next": "15.0.3", "react": "^18.3.1", @@ -21,6 +23,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", "react-toastify": "^11.0.2", + "sockjs-client": "^1.6.1", "tailwind-merge": "^2.5.5", "zod": "^3.23.8", "zustand": "^5.0.1" @@ -4379,6 +4382,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stomp/stompjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.0.0.tgz", + "integrity": "sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw==", + "license": "Apache-2.0" + }, "node_modules/@storybook/addon-actions": { "version": "8.4.5", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.4.5.tgz", @@ -5753,6 +5762,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/sockjs-client": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.4.tgz", + "integrity": "sha512-zk+uFZeWyvJ5ZFkLIwoGA/DfJ+pYzcZ8eH4H/EILCm2OBZyHH6Hkdna1/UWL/CFruh5wj6ES7g75SvUB0VsH5w==", + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -10524,6 +10539,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -10674,6 +10698,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -11506,6 +11542,12 @@ "entities": "^2.0.0" } }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -11714,7 +11756,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -14524,7 +14565,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/msw": { @@ -16116,7 +16156,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, "license": "MIT" }, "node_modules/queue": { @@ -16564,7 +16603,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -16826,7 +16864,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -17228,6 +17265,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/sockjs-client/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -18753,7 +18818,6 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, "license": "MIT", "dependencies": { "querystringify": "^2.1.1", @@ -19026,6 +19090,29 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", diff --git a/package.json b/package.json index 7c16882e..1cb6f6bf 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,11 @@ "@headlessui/react": "^2.2.0", "@hookform/resolvers": "^3.9.1", "@lukemorales/query-key-factory": "^1.3.4", + "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.61.3", "@tanstack/react-query-devtools": "^5.61.3", "@types/react-datepicker": "^6.2.0", + "@types/sockjs-client": "^1.5.4", "axios": "^1.7.8", "next": "15.0.3", "react": "^18.3.1", @@ -39,6 +41,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", "react-toastify": "^11.0.2", + "sockjs-client": "^1.6.1", "tailwind-merge": "^2.5.5", "zod": "^3.23.8", "zustand": "^5.0.1" From a32be71c87287a1ca79a30cdb5f6c0c10f3f1a51 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Mon, 30 Dec 2024 18:08:24 +0900 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8[Feat]=20=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/chat/page.tsx | 2 + src/features/auth/api/auth.ts | 12 ++++ src/features/chat/utils/socket.ts | 99 +++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 src/features/chat/utils/socket.ts diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 06b496bb..d6ab24e6 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,10 +1,12 @@ import React from 'react'; import ChatContainer from '@/features/chat/container/ChatContainer'; import ChatHeader from '@/features/chat/components/chat-header/ChatHeader'; +import ChatComponent from '@/features/chat/components/ChatComponent'; function ChatPage() { return ( <> + diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts index d2231695..f43f6c56 100644 --- a/src/features/auth/api/auth.ts +++ b/src/features/auth/api/auth.ts @@ -7,6 +7,10 @@ import { showToast } from '@/components/toast/toast'; import { getCookie } from '@/features/auth/utils/cookies'; import { User } from '../types/user'; import { SignUpFormData } from '../types/sign-up.schema'; +import { + initializeSocket, + disconnectSocket, +} from '@/features/chat/utils/socket'; export const login = async (data: LoginFormData) => { try { @@ -21,6 +25,12 @@ export const login = async (data: LoginFormData) => { setIsLoggedIn(true); await getUserInfo(); + const token = getCookie('auth_token'); + console.log('token', token); + if (token) { + initializeSocket(token); + } + showToast({ message: '로그인에 성공했습니다.', type: 'success' }); return response; } catch (error) { @@ -43,6 +53,8 @@ export const logout = async () => { const response = await logoutServer.bind(null, accessToken, refreshToken)(); + disconnectSocket(); + const { setIsLoggedIn, setUser } = useAuthStore.getState(); setIsLoggedIn(false); setUser(null); diff --git a/src/features/chat/utils/socket.ts b/src/features/chat/utils/socket.ts new file mode 100644 index 00000000..9caadc74 --- /dev/null +++ b/src/features/chat/utils/socket.ts @@ -0,0 +1,99 @@ +import SockJS from 'sockjs-client'; +import { Client } from '@stomp/stompjs'; +import { getMyJoined } from '@/features/profile/api/myJoinedApi'; +import { BookClub } from '@/types/bookclubs'; + +let stompClient: Client | null = null; + +export interface ChatMessage { + id: string; + bookClubId: string; + date: string; + userId: string; + userNickname: string; + type: 'CHAT' | 'JOIN' | 'EXIT'; + content: string; +} + +export const initializeSocket = async (token: string) => { + try { + const { bookClubs } = await getMyJoined({ + order: 'DESC', + size: 10, + page: 1, + }); + + const client = new Client({ + webSocketFactory: () => + new SockJS(`${process.env.NEXT_PUBLIC_API_URL}/ws?token=${token}`), + reconnectDelay: 5000, + heartbeatIncoming: 4000, + heartbeatOutgoing: 4000, + debug: function (str) { + console.log('STOMP: ' + str); + }, + }); + + client.onConnect = () => { + bookClubs.forEach((club: BookClub) => { + const subscription = client.subscribe( + `/topic/group-chat/${club.id}`, + (message) => { + const chatMessage: ChatMessage = JSON.parse(message.body); + console.log(`모임 ${club.title}의 새 메시지 수신:`, chatMessage); + }, + ); + console.log(`모임 ${club.id} 구독 완료:`, subscription.id); + }); + }; + + client.onDisconnect = () => { + console.log('소켓 연결 끊김'); + }; + + client.onStompError = (frame) => { + console.error('Stomp 에러:', frame); + }; + + client.activate(); + stompClient = client; + + return client; + } catch (error) { + console.error('모임 정보 조회 실패:', error); + } +}; + +export const disconnectSocket = () => { + if (stompClient) { + stompClient.deactivate(); + stompClient = null; + } +}; + +export const getStompClient = () => { + if (!stompClient) { + throw new Error('소켓이 초기화되지 않았습니다.'); + } + return stompClient; +}; + +export const sendMessage = (roomId: number, content: string) => { + const client = getStompClient(); + + const message = { + content, + }; + + console.log(`메시지 전송 시도: roomId=${roomId}, content=${content}`); + + try { + client.publish({ + destination: `/app/group-chat/${roomId}/sendMessage`, + body: JSON.stringify(message), + }); + console.log('메시지 전송 성공'); + } catch (error) { + console.error('메시지 전송 실패:', error); + } +}; From 63aabfb5a98c49bac42f88025743afc52e601513 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Tue, 31 Dec 2024 14:04:02 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=92=84[Design]=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/chat/[id]/page.tsx | 75 ++++++++++ src/app/chat/page.tsx | 15 +- .../chat-bubble-list/ChatBubbleList.tsx | 2 +- .../chat/components/ChatComponents.tsx | 31 ++++ .../chat/components/chat-card/ChatCard.tsx | 2 +- .../components/chat-header/ChatHeader.tsx | 15 -- .../chat/container/BookClubChatContainer.tsx | 134 +++--------------- src/features/chat/utils/socket.ts | 31 ++-- 8 files changed, 157 insertions(+), 148 deletions(-) create mode 100644 src/app/chat/[id]/page.tsx create mode 100644 src/features/chat/components/ChatComponents.tsx delete mode 100644 src/features/chat/components/chat-header/ChatHeader.tsx diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx new file mode 100644 index 00000000..4f3d9702 --- /dev/null +++ b/src/app/chat/[id]/page.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React from 'react'; +import ChatBubbleList from '@/features/chat-room/container/chat-bubble-list/ChatBubbleList'; +import { usePathname } from 'next/navigation'; +import { GroupedMessage } from '@/features/chat-room/types/chatBubbleList'; +import ChatCard from '@/features/chat/components/chat-card/ChatCard'; +import ParticipantCounter from '@/components/participant-counter/ParticipantCounter'; + +const groupedMessagesExample: GroupedMessage[] = [ + { + date: '2024년 3월 15일', + messages: [ + { + type: 'join', + date: '2024-03-15T14:29:00', + user: '김철수', + }, + { + type: 'chat', + date: '2024-03-15T14:30:00', + sender: '김철수', + senderId: '123', + content: '안녕하세요!', + profileImage: '/images/profile.png', + }, + { + type: 'chat', + date: '2024-03-15T14:31:00', + sender: '이영희', + senderId: '456', + content: '환영합니다!', + profileImage: '/images/profile.png', + }, + ], + }, +]; + +function ChatRoomPage() { + const pathname = usePathname(); + const chatId = pathname?.split('/').pop() || ''; + console.log('chatId: ', chatId); + + return ( + <> +
+
+
+

채팅

+ +
+ console.log('헤더 클릭'), + }} + /> +
+
+ {}} + /> + + ); +} + +export default ChatRoomPage; diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index d6ab24e6..1f4a4e9f 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,13 +1,20 @@ import React from 'react'; import ChatContainer from '@/features/chat/container/ChatContainer'; -import ChatHeader from '@/features/chat/components/chat-header/ChatHeader'; -import ChatComponent from '@/features/chat/components/ChatComponent'; - +import HeaderSection from '@/components/common-layout/HeaderSection'; +import ChatComponent from '@/features/chat/components/ChatComponents'; function ChatPage() { return ( <> - + + 채팅에 활발히 +
+ 참여해 보세요! + + } + /> ); diff --git a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx index 075c5508..250e2f84 100644 --- a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx +++ b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx @@ -36,7 +36,7 @@ function ChatBubbleList({ }; return ( -
+
{groupedMessages.map((group, groupIndex) => (
diff --git a/src/features/chat/components/ChatComponents.tsx b/src/features/chat/components/ChatComponents.tsx new file mode 100644 index 00000000..c328ba53 --- /dev/null +++ b/src/features/chat/components/ChatComponents.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useState } from 'react'; +import { sendMessage } from '../utils/socket'; + +const ChatComponent = () => { + const [message, setMessage] = useState(''); + const roomId = 44; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (message.trim()) { + sendMessage(roomId, message); + setMessage(''); + } + }; + + return ( +
+ setMessage(e.target.value)} + placeholder="메시지를 입력하세요" + /> + +
+ ); +}; + +export default ChatComponent; diff --git a/src/features/chat/components/chat-card/ChatCard.tsx b/src/features/chat/components/chat-card/ChatCard.tsx index 36666cc7..4d20f415 100644 --- a/src/features/chat/components/chat-card/ChatCard.tsx +++ b/src/features/chat/components/chat-card/ChatCard.tsx @@ -32,7 +32,7 @@ function ChatCardBox({ return (
-

- 채팅에 활발히 -
- 참여해 보세요! -

- - ); -} - -export default ChatHeader; diff --git a/src/features/chat/container/BookClubChatContainer.tsx b/src/features/chat/container/BookClubChatContainer.tsx index f1f17226..a84a95a7 100644 --- a/src/features/chat/container/BookClubChatContainer.tsx +++ b/src/features/chat/container/BookClubChatContainer.tsx @@ -2,123 +2,35 @@ import React from 'react'; import ChatCard from '@/features/chat/components/chat-card/ChatCard'; - -interface BookClubChatData { - imageUrl: string; - isHost: boolean; - title: string; - currentParticipants: number; - lastMessage: string; - lastMessageTime: string; - unreadCount: number; -} +import { bookClubs } from '@/api/book-club/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { BookClubProps } from '@/features/chat/components/chat-card/types/variantChatCard'; +import Link from 'next/link'; export default function BookClubChatContainer() { - const mockBookClubChats: BookClubChatData[] = [ - { - imageUrl: '/images/profile.png', - isHost: true, - title: '철학으로 보는 현대사회', - currentParticipants: 8, - lastMessage: '다음 모임은 니체의 차라투스트라를 읽어볼까요?', - lastMessageTime: '11:30', - unreadCount: 5, - }, - { - imageUrl: '/images/profile.png', - isHost: false, - title: 'SF 소설 읽기 모임', - currentParticipants: 12, - lastMessage: '듄 시리즈 완독하신 분들 계신가요?', - lastMessageTime: '09:15', - unreadCount: 2, - }, - { - imageUrl: '/images/profile.png', - isHost: true, - title: '심리학 북클럽', - currentParticipants: 6, - lastMessage: '이번주 토론 주제는 프로이트의 꿈의 해석입니다.', - lastMessageTime: '어제', - unreadCount: 0, - }, - { - imageUrl: '/images/profile.png', - isHost: false, - title: '시 읽는 청춘들', - currentParticipants: 15, - lastMessage: '이상의 시를 함께 감상해보아요', - lastMessageTime: '어제', - unreadCount: 7, - }, - { - imageUrl: '/images/profile.png', - isHost: false, - title: '경제경영 독서모임', - currentParticipants: 10, - lastMessage: '이번 주 도서: 넛지', - lastMessageTime: '2일 전', - unreadCount: 1, - }, - { - imageUrl: '/images/profile.png', - isHost: true, - title: '세계문학 산책', - currentParticipants: 7, - lastMessage: '카프카 변신 읽고 오세요!', - lastMessageTime: '3일 전', - unreadCount: 4, - }, - { - imageUrl: '/images/profile.png', - isHost: false, - title: '에세이 교환일기', - currentParticipants: 4, - lastMessage: '무라카미 하루키의 에세이집 추천합니다', - lastMessageTime: '1주일 전', - unreadCount: 0, - }, - { - imageUrl: '/images/profile.png', - isHost: true, - title: '미스터리 소설 탐독', - currentParticipants: 9, - lastMessage: '애거사 크리스티 특집 시작합니다', - lastMessageTime: '1주일 전', - unreadCount: 12, - }, - { - imageUrl: '/images/profile.png', - isHost: false, - title: '인문학 스터디', - currentParticipants: 11, - lastMessage: '유발 하라리의 사피엔스 함께 읽어요', - lastMessageTime: '2주일 전', - unreadCount: 3, - }, - { - imageUrl: '/images/profile.png', - isHost: true, - title: '고전문학 독파', - currentParticipants: 6, - lastMessage: '돈키호테 완독 인증하신 분들 계신가요?', - lastMessageTime: '2주일 전', - unreadCount: 8, - }, - ]; + const { queryKey, queryFn } = bookClubs.myJoined({ order: 'DESC' }); + const { data, isLoading, error } = useQuery({ + queryKey, + queryFn, + }); + + const bookClubChats = data?.data?.bookClubs || []; + + if (isLoading) return
로딩중...
; + if (error) return
에러가 발생했습니다
; return (
- {mockBookClubChats.map((chat, index) => ( - console.log('채팅방 클릭'), - }} - /> + {bookClubChats.map((chat: BookClubProps, id: number) => ( + + + ))}
diff --git a/src/features/chat/utils/socket.ts b/src/features/chat/utils/socket.ts index 9caadc74..82ab4ad9 100644 --- a/src/features/chat/utils/socket.ts +++ b/src/features/chat/utils/socket.ts @@ -1,7 +1,7 @@ import SockJS from 'sockjs-client'; import { Client } from '@stomp/stompjs'; -import { getMyJoined } from '@/features/profile/api/myJoinedApi'; import { BookClub } from '@/types/bookclubs'; +import apiClient from '@/lib/utils/apiClient'; let stompClient: Client | null = null; @@ -17,33 +17,32 @@ export interface ChatMessage { export const initializeSocket = async (token: string) => { try { - const { bookClubs } = await getMyJoined({ - order: 'DESC', - size: 10, - page: 1, + const response = await apiClient.get('/book-clubs/my-joined', { + params: { + order: 'DESC', + size: 10, + page: 1, + }, }); + const { bookClubs } = response.data; + const client = new Client({ webSocketFactory: () => new SockJS(`${process.env.NEXT_PUBLIC_API_URL}/ws?token=${token}`), reconnectDelay: 5000, heartbeatIncoming: 4000, heartbeatOutgoing: 4000, - debug: function (str) { - console.log('STOMP: ' + str); - }, }); client.onConnect = () => { + console.log('소켓 연결 성공'); bookClubs.forEach((club: BookClub) => { - const subscription = client.subscribe( - `/topic/group-chat/${club.id}`, - (message) => { - const chatMessage: ChatMessage = JSON.parse(message.body); - console.log(`모임 ${club.title}의 새 메시지 수신:`, chatMessage); - }, - ); - console.log(`모임 ${club.id} 구독 완료:`, subscription.id); + console.log('구독 시도:', club.id); + client.subscribe(`/topic/group-chat/${club.id}`, (message) => { + const chatMessage: ChatMessage = JSON.parse(message.body); + console.log(`모임 ${club.title}의 새 메시지 수신:`, chatMessage); + }); }); }; From 33dc630951c8543be054d643d5182b940403e85d Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Tue, 31 Dec 2024 14:41:33 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=92=84[Design]=20=EB=92=A4=EB=A1=9C?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=EB=B2=84=ED=8A=BC,=20=ED=96=84=EB=B2=84?= =?UTF-8?q?=EA=B1=B0=20=EB=A9=94=EB=89=B4=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/icons/GoBackIcon.tsx | 24 ++++++++++++++++++++++++ public/icons/HamburgerMenuIcon.tsx | 30 ++++++++++++++++++++++++++++++ src/app/chat/[id]/page.tsx | 29 +++++++++++++++++++++++------ 3 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 public/icons/GoBackIcon.tsx create mode 100644 public/icons/HamburgerMenuIcon.tsx diff --git a/public/icons/GoBackIcon.tsx b/public/icons/GoBackIcon.tsx new file mode 100644 index 00000000..766f9918 --- /dev/null +++ b/public/icons/GoBackIcon.tsx @@ -0,0 +1,24 @@ +function GoBackIcon({ + width = 14, + height = 14, + strokeColor = '#B4B5B6', + ...props +}) { + return ( + + + + ); +} + +export default GoBackIcon; diff --git a/public/icons/HamburgerMenuIcon.tsx b/public/icons/HamburgerMenuIcon.tsx new file mode 100644 index 00000000..3d5dbb25 --- /dev/null +++ b/public/icons/HamburgerMenuIcon.tsx @@ -0,0 +1,30 @@ +function HamburgerMenuIcon({ + width = 18, + height = 14, + strokeColor = '#B4B5B6', + ...props +}) { + return ( + + + + ); +} + +export default HamburgerMenuIcon; diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 4f3d9702..a80aa851 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -6,6 +6,9 @@ import { usePathname } from 'next/navigation'; import { GroupedMessage } from '@/features/chat-room/types/chatBubbleList'; import ChatCard from '@/features/chat/components/chat-card/ChatCard'; import ParticipantCounter from '@/components/participant-counter/ParticipantCounter'; +import IconButton from '@/components/icon-button/IconButton'; +import GoBackIcon from '../../../../public/icons/GoBackIcon'; +import HamburgerMenuIcon from '../../../../public/icons/HamburgerMenuIcon'; const groupedMessagesExample: GroupedMessage[] = [ { @@ -42,12 +45,26 @@ function ChatRoomPage() { console.log('chatId: ', chatId); return ( - <> +
-
-
-

채팅

- +
+
+
+ } + onClick={() => console.log('채팅 버튼 클릭')} + className="bg-gray-light-02" + /> +

채팅

+ +
+
+ } + onClick={() => console.log('메뉴 열기 버튼 클릭')} + className="bg-gray-light-02" + /> +
{}} /> - +
); } From 0a50818ad3fe2eb036494e9e9ae8c0c659b0ce07 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:50:19 +0900 Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=A8[Feat]=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5,=20=EC=B5=9C=EC=8B=A0=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 5 +- src/app/chat/[id]/page.tsx | 154 +++++++++++++----- src/app/chat/page.tsx | 4 +- .../chat-bubble-list/ChatBubbleList.tsx | 6 +- .../components/ChatMessage.tsx | 9 +- .../components/SystemMessage.tsx | 2 +- .../chat-room/hooks/useMessageRenderer.ts | 7 +- .../chat-room/types/chatBubbleList.ts | 12 +- .../chat/container/BookClubChatContainer.tsx | 62 +++++-- src/features/chat/utils/chatRoom.ts | 10 ++ src/features/chat/utils/socket.ts | 119 ++++++++++++-- 11 files changed, 304 insertions(+), 86 deletions(-) create mode 100644 src/features/chat/utils/chatRoom.ts diff --git a/next.config.ts b/next.config.ts index 1be1526f..51ca252f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,10 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { images: { - domains: ['sprint-fe-project.s3.ap-northeast-2.amazonaws.com'], + domains: [ + 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com', + 'codeit-bookco.s3.ap-northeast-2.amazonaws.com', + ], }, instrumentation: { enabled: true, diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index a80aa851..2fe8d1b6 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -1,51 +1,124 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import ChatBubbleList from '@/features/chat-room/container/chat-bubble-list/ChatBubbleList'; import { usePathname } from 'next/navigation'; -import { GroupedMessage } from '@/features/chat-room/types/chatBubbleList'; import ChatCard from '@/features/chat/components/chat-card/ChatCard'; import ParticipantCounter from '@/components/participant-counter/ParticipantCounter'; import IconButton from '@/components/icon-button/IconButton'; import GoBackIcon from '../../../../public/icons/GoBackIcon'; import HamburgerMenuIcon from '../../../../public/icons/HamburgerMenuIcon'; - -const groupedMessagesExample: GroupedMessage[] = [ - { - date: '2024년 3월 15일', - messages: [ - { - type: 'join', - date: '2024-03-15T14:29:00', - user: '김철수', - }, - { - type: 'chat', - date: '2024-03-15T14:30:00', - sender: '김철수', - senderId: '123', - content: '안녕하세요!', - profileImage: '/images/profile.png', - }, - { - type: 'chat', - date: '2024-03-15T14:31:00', - sender: '이영희', - senderId: '456', - content: '환영합니다!', - profileImage: '/images/profile.png', - }, - ], - }, -]; +import MessageInput from '@/components/input/message-input/MessageInput'; +import { + ChatHistoryResponse, + ChatMessage, + getChatHistory, + sendMessage, + subscribeToChat, +} from '@/features/chat/utils/socket'; +import { + ChatMessageType, + GroupedMessage, + SystemMessageType, +} from '@/features/chat-room/types/chatBubbleList'; function ChatRoomPage() { const pathname = usePathname(); const chatId = pathname?.split('/').pop() || ''; - console.log('chatId: ', chatId); + const [message, setMessage] = useState(''); + const [chatHistory, setChatHistory] = useState({ + historyResponses: [], + }); + + useEffect(() => { + const loadChatHistory = async () => { + try { + const history = await getChatHistory(Number(chatId)); + setChatHistory(history); + } catch (error) { + console.error('채팅 히스토리 로딩 실패:', error); + } + }; + + const handleNewMessage = (message: ChatMessage) => { + setChatHistory((prev: any) => { + if (!prev?.historyResponses?.length) { + return { + historyResponses: [ + { + date: new Date().toISOString(), + messages: [message], + }, + ], + }; + } + + return { + ...prev, + historyResponses: [ + ...prev.historyResponses.slice(0, -1), + { + ...prev.historyResponses[prev.historyResponses.length - 1], + messages: [ + ...prev.historyResponses[prev.historyResponses.length - 1] + .messages, + message, + ], + }, + ], + }; + }); + }; + + loadChatHistory(); + const subscription = subscribeToChat(Number(chatId), handleNewMessage); + + return () => { + subscription.unsubscribe(); + }; + }, [chatId]); + + const handleMessageChange = (e: React.ChangeEvent) => { + setMessage(e.target.value); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (message.trim()) { + sendMessage(Number(chatId), message); + setMessage(''); + } + }; + + const convertToGroupedMessage = ( + history: ChatHistoryResponse, + ): GroupedMessage[] => { + return history.historyResponses.map((response) => ({ + date: response.date, + messages: response.messages.map((msg) => { + if (msg.type === 'CHAT') { + return { + type: 'CHAT', + id: msg.id, + date: msg.date, + userId: msg.userId, + userNickname: msg.userNickname, + content: msg.content, + } as ChatMessageType; + } else { + return { + type: msg.type, + id: msg.id, + date: msg.date, + user: msg.userNickname, + } as SystemMessageType; + } + }), + })); + }; return ( -
+
@@ -80,11 +153,16 @@ function ChatRoomPage() { />
- {}} - /> +
+ {}} + /> +
+
+ +
); } diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 1f4a4e9f..e0f8acbf 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,11 +1,10 @@ import React from 'react'; import ChatContainer from '@/features/chat/container/ChatContainer'; import HeaderSection from '@/components/common-layout/HeaderSection'; -import ChatComponent from '@/features/chat/components/ChatComponents'; + function ChatPage() { return ( <> - @@ -15,6 +14,7 @@ function ChatPage() { } /> + ); diff --git a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx index 250e2f84..e4c7b14a 100644 --- a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx +++ b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx @@ -25,12 +25,12 @@ function ChatBubbleList({ index: number, ) => { switch (message.type) { - case 'chat': + case 'CHAT': return ( ); - case 'join': - case 'leave': + case 'JOIN': + case 'LEAVE': return ; } }; diff --git a/src/features/chat-room/container/chat-bubble-list/components/ChatMessage.tsx b/src/features/chat-room/container/chat-bubble-list/components/ChatMessage.tsx index b29655cc..ebb1d0b5 100644 --- a/src/features/chat-room/container/chat-bubble-list/components/ChatMessage.tsx +++ b/src/features/chat-room/container/chat-bubble-list/components/ChatMessage.tsx @@ -18,7 +18,7 @@ function ChatMessage({ time, onProfileClick, }: ChatMessageProps) { - const { senderId, content, sender, profileImage } = message; + const { userId, content, userNickname } = message; return isMyMessage ? ( @@ -28,10 +28,9 @@ function ChatMessage({ props={{ content, time, - name: sender, - profileImage: profileImage || '', - isHost: senderId === hostId, - onProfileClick: () => onProfileClick?.(senderId), + name: userNickname, + isHost: userId === hostId, + onProfileClick: () => onProfileClick?.(userId), isConsecutive, }} /> diff --git a/src/features/chat-room/container/chat-bubble-list/components/SystemMessage.tsx b/src/features/chat-room/container/chat-bubble-list/components/SystemMessage.tsx index 1126ba25..9ab4d5bd 100644 --- a/src/features/chat-room/container/chat-bubble-list/components/SystemMessage.tsx +++ b/src/features/chat-room/container/chat-bubble-list/components/SystemMessage.tsx @@ -13,7 +13,7 @@ function SystemMessage({ message }: SystemMessageProps) { variant="SYSTEM" props={{ username: user, - action: type === 'join' ? 'JOIN' : 'LEAVE', + action: type === 'JOIN' ? 'JOIN' : 'LEAVE', }} /> ); diff --git a/src/features/chat-room/hooks/useMessageRenderer.ts b/src/features/chat-room/hooks/useMessageRenderer.ts index 7b233ca2..43978598 100644 --- a/src/features/chat-room/hooks/useMessageRenderer.ts +++ b/src/features/chat-room/hooks/useMessageRenderer.ts @@ -21,15 +21,14 @@ export const useMessageRenderer = ({ currentMessage: ChatMessageType, prevMessage?: Message, ) => { - if (!prevMessage || prevMessage.type !== 'chat') return false; + if (!prevMessage || prevMessage.type !== 'CHAT') return false; const timeDiff = new Date(currentMessage.date).getTime() - new Date(prevMessage.date).getTime(); return ( - prevMessage.senderId === currentMessage.senderId && - timeDiff < 10 * 60 * 1000 + prevMessage.userId === currentMessage.userId && timeDiff < 10 * 60 * 1000 ); }; @@ -39,7 +38,7 @@ export const useMessageRenderer = ({ index: number, ) => ({ message, - isMyMessage: user?.id == message.senderId, + isMyMessage: user?.id == message.userId, isConsecutive: isConsecutiveMessage(message, messages[index - 1]), hostId, time: format(new Date(message.date), 'HH:mm'), diff --git a/src/features/chat-room/types/chatBubbleList.ts b/src/features/chat-room/types/chatBubbleList.ts index e53aa3a8..e5178116 100644 --- a/src/features/chat-room/types/chatBubbleList.ts +++ b/src/features/chat-room/types/chatBubbleList.ts @@ -1,18 +1,18 @@ export interface BaseMessage { - type: 'chat' | 'join' | 'leave'; + type: 'CHAT' | 'JOIN' | 'LEAVE'; date: string; + id: number; } export interface ChatMessageType extends BaseMessage { - type: 'chat'; - sender: string; - senderId: string | number; + type: 'CHAT'; + userNickname: string; + userId: number; content: string; - profileImage?: string; } export interface SystemMessageType extends BaseMessage { - type: 'join' | 'leave'; + type: 'JOIN' | 'LEAVE'; user: string; } diff --git a/src/features/chat/container/BookClubChatContainer.tsx b/src/features/chat/container/BookClubChatContainer.tsx index a84a95a7..34eb16e3 100644 --- a/src/features/chat/container/BookClubChatContainer.tsx +++ b/src/features/chat/container/BookClubChatContainer.tsx @@ -1,20 +1,51 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import ChatCard from '@/features/chat/components/chat-card/ChatCard'; import { bookClubs } from '@/api/book-club/react-query'; import { useQuery } from '@tanstack/react-query'; import { BookClubProps } from '@/features/chat/components/chat-card/types/variantChatCard'; import Link from 'next/link'; +import { getRecentChats, getStompClient } from '@/features/chat/utils/socket'; +import { findRecentMessage } from '@/features/chat/utils/chatRoom'; export default function BookClubChatContainer() { + const [recentMessages, setRecentMessages] = useState< + Array<{ + bookClubId: number; + content: string; + date: string; + }> + >([]); + const { queryKey, queryFn } = bookClubs.myJoined({ order: 'DESC' }); + const { data, isLoading, error } = useQuery({ queryKey, queryFn, }); + useEffect(() => { + const fetchRecentChats = async () => { + try { + const response = await getRecentChats(); + console.log('최근 채팅 내용 조회 성공:', response); + setRecentMessages(response || []); + } catch (error) { + console.error('최근 채팅 내용 조회 실패:', error); + } + }; + + try { + getStompClient(); + fetchRecentChats(); + } catch (error) { + console.error('소켓이 초기화되지 않았습니다:', error); + } + }, []); + const bookClubChats = data?.data?.bookClubs || []; + console.log('bookClubChats', bookClubChats); if (isLoading) return
로딩중...
; if (error) return
에러가 발생했습니다
; @@ -22,16 +53,25 @@ export default function BookClubChatContainer() { return (
- {bookClubChats.map((chat: BookClubProps, id: number) => ( - - - - ))} + {bookClubChats.map((bookClub: BookClubProps, id: number) => { + const recentMessage = findRecentMessage( + recentMessages, + Number(bookClub.id), + ); + + return ( + + + + ); + })}
); diff --git a/src/features/chat/utils/chatRoom.ts b/src/features/chat/utils/chatRoom.ts new file mode 100644 index 00000000..22334fb1 --- /dev/null +++ b/src/features/chat/utils/chatRoom.ts @@ -0,0 +1,10 @@ +export const findRecentMessage = ( + recentMessages: Array<{ + bookClubId: number; + content: string; + date: string; + }>, + chatId: number, +) => { + return recentMessages.find((msg) => msg.bookClubId === chatId); +}; diff --git a/src/features/chat/utils/socket.ts b/src/features/chat/utils/socket.ts index 82ab4ad9..880c744c 100644 --- a/src/features/chat/utils/socket.ts +++ b/src/features/chat/utils/socket.ts @@ -6,13 +6,23 @@ import apiClient from '@/lib/utils/apiClient'; let stompClient: Client | null = null; export interface ChatMessage { - id: string; - bookClubId: string; + id: number; + bookClubId: number; date: string; - userId: string; + userId: number; userNickname: string; - type: 'CHAT' | 'JOIN' | 'EXIT'; + type: 'CHAT' | 'JOIN' | 'LEAVE'; content: string; + user?: string; +} + +export interface HistoryResponse { + date: string; + messages: ChatMessage[]; +} + +export interface ChatHistoryResponse { + historyResponses: HistoryResponse[]; } export const initializeSocket = async (token: string) => { @@ -37,6 +47,7 @@ export const initializeSocket = async (token: string) => { client.onConnect = () => { console.log('소켓 연결 성공'); + bookClubs.forEach((club: BookClub) => { console.log('구독 시도:', club.id); client.subscribe(`/topic/group-chat/${club.id}`, (message) => { @@ -80,19 +91,97 @@ export const getStompClient = () => { export const sendMessage = (roomId: number, content: string) => { const client = getStompClient(); - const message = { - content, - }; + if (content === '') { + return; + } console.log(`메시지 전송 시도: roomId=${roomId}, content=${content}`); - try { - client.publish({ - destination: `/app/group-chat/${roomId}/sendMessage`, - body: JSON.stringify(message), + client.publish({ + destination: `/app/group-chat/${roomId}/sendMessage`, + body: JSON.stringify({ content }), + }); + console.log('메시지 전송 성공'); +}; + +export const getChatHistory = (roomId: number) => { + const client = getStompClient(); + + return new Promise((resolve, reject) => { + const subscription = client.subscribe( + '/user/queue/chatHistory', + (message) => { + try { + const historyData: ChatHistoryResponse = JSON.parse(message.body); + console.log('채팅 히스토리 수신:', historyData); + resolve(historyData); + subscription.unsubscribe(); + } catch (error) { + console.error('채팅 히스토리 파싱 실패:', error); + reject(error); + subscription.unsubscribe(); + } + }, + ); + + try { + client.publish({ + destination: `/app/group-chat/history/${roomId}`, + body: JSON.stringify({}), + }); + console.log('채팅 히스토리 요청 전송'); + } catch (error) { + console.error('채팅 히스토리 요청 실패:', error); + subscription.unsubscribe(); + reject(error); + } + }); +}; + +export const getRecentChats = () => { + const client = getStompClient(); + console.log('getRecentChats - 클라이언트 상태:', { + connected: client.connected, + active: client.active, + }); + + return new Promise((resolve, reject) => { + const subscription = client.subscribe('/user/queue/recent', (message) => { + console.log('구독 콜백 실행됨'); + try { + const recentData: ChatMessage[] = JSON.parse(message.body); + console.log('최신 채팅 데이터:', recentData); + resolve(recentData); + subscription.unsubscribe(); + } catch (error) { + console.error('최신 채팅 파싱 실패:', error); + reject(error); + subscription.unsubscribe(); + } }); - console.log('메시지 전송 성공'); - } catch (error) { - console.error('메시지 전송 실패:', error); - } + + try { + console.log('서버로 요청 전송 시도'); + client.publish({ + destination: '/app/group-chat/recent', + body: JSON.stringify({}), + }); + console.log('서버로 요청 전송 완료'); + } catch (error) { + console.error('최신 채팅 요청 실패:', error); + subscription.unsubscribe(); + reject(error); + } + }); +}; + +export const subscribeToChat = ( + roomId: number, + callback: (message: ChatMessage) => void, +) => { + const client = getStompClient(); + return client.subscribe(`/topic/group-chat/${roomId}`, (message) => { + const chatMessage: ChatMessage = JSON.parse(message.body); + callback(chatMessage); + }); }; From dc3769fc00700d179318de63b36e5dfe407720cc Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 3 Jan 2025 13:34:37 +0900 Subject: [PATCH 06/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F[Refactor]=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=20#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatBubbleList.stories.tsx | 83 ++++++++++--------- src/middleware.ts | 1 + 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.stories.tsx b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.stories.tsx index 5dd411e7..53c178d8 100644 --- a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.stories.tsx +++ b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.stories.tsx @@ -45,68 +45,68 @@ const mockGroupedMessages: GroupedMessage[] = [ date: '2024년 1월 15일 월요일', messages: [ { - type: 'chat', - sender: '김정호', - senderId: '1', + id: 1, + type: 'CHAT', + userNickname: '김정호', + userId: 1, content: '안녕하세요', date: '2024-01-15T10:00:00', - profileImage: 'https://picsum.photos/200', }, { - type: 'chat', - sender: '김정호', - senderId: '1', + id: 2, + type: 'CHAT', + userNickname: '김정호', + userId: 1, content: '오늘 회의 자료 공유드립니다', date: '2024-01-15T10:00:05', - profileImage: 'https://picsum.photos/200', }, { - type: 'chat', - sender: '박유나', - senderId: '3', + id: 3, + type: 'CHAT', + userNickname: '박유나', + userId: 3, content: '네, 감사합니다!', date: '2024-01-15T10:00:10', - profileImage: 'https://picsum.photos/200', }, { - type: 'chat', - sender: '이민수', - senderId: '2', + id: 4, + type: 'CHAT', + userNickname: '이민수', + userId: 2, content: '네, 확인했습니다', date: '2024-01-15T10:01:00', - profileImage: 'https://picsum.photos/200', }, { - type: 'chat', - sender: '이민수', - senderId: '2', + id: 5, + type: 'CHAT', + userNickname: '이민수', + userId: 2, content: '수정사항이 있을 것 같아요', date: '2024-01-15T10:01:10', - profileImage: 'https://picsum.photos/200', }, { - type: 'chat', - sender: '김정호', - senderId: '1', + id: 6, + type: 'CHAT', + userNickname: '김정호', + userId: 1, content: '어떤 부분인가요?', date: '2024-01-15T10:01:30', - profileImage: 'https://picsum.photos/200', }, { - type: 'chat', - sender: '김정호', - senderId: '1', + id: 7, + type: 'CHAT', + userNickname: '김정호', + userId: 1, content: '회의 시작하겠습니다', date: '2024-01-15T10:10:00', - profileImage: 'https://picsum.photos/200', }, { - type: 'chat', - sender: '김정호', - senderId: '1', + id: 8, + type: 'CHAT', + userNickname: '김정호', + userId: 1, content: '자료 화면에 공유하겠습니다', date: '2024-01-15T10:10:05', - profileImage: 'https://picsum.photos/200', }, ], }, @@ -114,23 +114,24 @@ const mockGroupedMessages: GroupedMessage[] = [ date: '2024년 1월 16일 화요일', messages: [ { - type: 'chat', - sender: '김정호', - senderId: '1', + id: 9, + type: 'CHAT', + userNickname: '김정호', + userId: 1, content: '오늘 회의 시작하겠습니다', date: '2024-01-16T09:00:00', - profileImage: 'https://picsum.photos/200', }, { - type: 'chat', - sender: '이민수', - senderId: '2', + id: 10, + type: 'CHAT', + userNickname: '이민수', + userId: 2, content: '네 알겠습니다', date: '2024-01-16T09:01:00', - profileImage: 'https://picsum.photos/200', }, { - type: 'leave', + id: 11, + type: 'LEAVE', user: '박유나', date: '2024-01-16T09:30:00', }, diff --git a/src/middleware.ts b/src/middleware.ts index d311625c..5975a48a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -38,4 +38,5 @@ export async function middleware(request: NextRequest) { export const config = { matcher: ['/wish', '/profile', '/login', '/bookclub/create', '/chat'], + unstable_allowDynamic: ['**/node_modules/sockjs-client/**'], }; From e8934157038d31baf315b9591d77e3992d4f47db Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:09:51 +0900 Subject: [PATCH 07/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F[Refactor]=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/chat/[id]/page.tsx | 2 +- src/features/chat/container/BookClubChatContainer.tsx | 5 ++++- src/lib/utils/formatDateForUI.ts | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 2fe8d1b6..f23761bb 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -118,7 +118,7 @@ function ChatRoomPage() { }; return ( -
+
diff --git a/src/features/chat/container/BookClubChatContainer.tsx b/src/features/chat/container/BookClubChatContainer.tsx index 34eb16e3..641d96c9 100644 --- a/src/features/chat/container/BookClubChatContainer.tsx +++ b/src/features/chat/container/BookClubChatContainer.tsx @@ -8,6 +8,7 @@ import { BookClubProps } from '@/features/chat/components/chat-card/types/varian import Link from 'next/link'; import { getRecentChats, getStompClient } from '@/features/chat/utils/socket'; import { findRecentMessage } from '@/features/chat/utils/chatRoom'; +import { formatDateForUI } from '@/lib/utils/formatDateForUI'; export default function BookClubChatContainer() { const [recentMessages, setRecentMessages] = useState< @@ -66,7 +67,9 @@ export default function BookClubChatContainer() { props={{ ...bookClub, lastMessage: recentMessage?.content || '', - lastMessageTime: recentMessage?.date || '', + lastMessageTime: recentMessage?.date + ? formatDateForUI(recentMessage.date, 'CHAT_ROOM') + : '', }} /> diff --git a/src/lib/utils/formatDateForUI.ts b/src/lib/utils/formatDateForUI.ts index 351e94fd..91f3f19b 100644 --- a/src/lib/utils/formatDateForUI.ts +++ b/src/lib/utils/formatDateForUI.ts @@ -1,6 +1,6 @@ export const formatDateForUI = ( isoString: string, - outputFormat: 'KOREAN' | 'DATE_ONLY', + outputFormat: 'KOREAN' | 'DATE_ONLY' | 'CHAT_ROOM', ): string => { const date = new Date(isoString); @@ -21,6 +21,8 @@ export const formatDateForUI = ( return `${month}/${day}(${dayOfWeek}) ${meridiem} ${hour}:${minute}`; case 'DATE_ONLY': return `${year}.${month}.${day}`; + case 'CHAT_ROOM': + return `${month}.${day} ${meridiem} ${hour}:${minute}`; } }; From dc03ced69985ecf289c02ecc6babbe8fecb3abb5 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:29:23 +0900 Subject: [PATCH 08/10] =?UTF-8?q?=E2=9C=A8[Feat]=20chatRoomHeader=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0=EA=B2=B0=20#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/chat/[id]/page.tsx | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index f23761bb..930ea08f 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -21,6 +21,9 @@ import { GroupedMessage, SystemMessageType, } from '@/features/chat-room/types/chatBubbleList'; +import { useQuery } from '@tanstack/react-query'; +import { bookClubs } from '@/api/book-club/react-query'; +import { formatDateForUI } from '@/lib/utils/formatDateForUI'; function ChatRoomPage() { const pathname = usePathname(); @@ -30,6 +33,16 @@ function ChatRoomPage() { historyResponses: [], }); + const { queryKey, queryFn } = bookClubs.myJoined({ order: 'DESC' }); + const { data } = useQuery({ + queryKey, + queryFn, + }); + + const bookClubDetail = data?.data?.bookClubs?.find( + (club: any) => club.id === Number(chatId), + ); + useEffect(() => { const loadChatHistory = async () => { try { @@ -142,12 +155,15 @@ function ChatRoomPage() { console.log('헤더 클릭'), }} /> From 4a923e2922afa224a2c633627c6881defc0274ed Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 3 Jan 2025 17:28:18 +0900 Subject: [PATCH 09/10] =?UTF-8?q?=E2=9C=A8[Feat]=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=ED=9B=84=20=EB=B0=91=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/chat/[id]/page.tsx | 2 +- .../components/chat-bubble/components/atoms/atoms.tsx | 3 +-- .../container/chat-bubble-list/ChatBubbleList.tsx | 10 ++++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 930ea08f..8dd5e2a1 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -169,7 +169,7 @@ function ChatRoomPage() { />
-
+
{ export function ChatBubbleProfile({ name, - imageUrl, isHost, className, onClick, @@ -99,7 +98,7 @@ export function ChatBubbleProfile({ onClick={onClick} data-testid="profile-image" > - + {isHost && (
diff --git a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx index e4c7b14a..dcde2b87 100644 --- a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx +++ b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx @@ -2,6 +2,7 @@ import ChatMessage from './components/ChatMessage'; import SystemMessage from './components/SystemMessage'; import { useMessageRenderer } from '../../hooks/useMessageRenderer'; import { Message, GroupedMessage } from '../../types/chatBubbleList'; +import { useEffect, useRef } from 'react'; export interface ChatBubbleListProps { groupedMessages: GroupedMessage[]; @@ -14,6 +15,14 @@ function ChatBubbleList({ hostId, onProfileClick, }: ChatBubbleListProps) { + const chatRef = useRef(null); + + useEffect(() => { + if (chatRef.current) { + chatRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [groupedMessages]); + const { getChatMessageProps, getSystemMessageProps } = useMessageRenderer({ hostId, onProfileClick, @@ -51,6 +60,7 @@ function ChatBubbleList({ ))}
))} +
); } From 45a24566bb4be6c32bfcd9f12c0abbebdf07ecd1 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 3 Jan 2025 23:11:21 +0900 Subject: [PATCH 10/10] =?UTF-8?q?=E2=9C=A8[Feat]=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=EC=8B=9C=20=EC=86=8C=EC=BC=93=20=EC=9E=AC?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=20#245?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/chat/[id]/page.tsx | 49 ++++++++++++++++++- src/features/chat/container/ChatContainer.tsx | 19 ++++++- src/features/chat/utils/socket.ts | 10 ++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 8dd5e2a1..0e8154b4 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -24,6 +24,8 @@ import { import { useQuery } from '@tanstack/react-query'; import { bookClubs } from '@/api/book-club/react-query'; import { formatDateForUI } from '@/lib/utils/formatDateForUI'; +import { getCookie } from '@/features/auth/utils/cookies'; +import { initializeSocket } from '@/features/chat/utils/socket'; function ChatRoomPage() { const pathname = usePathname(); @@ -33,6 +35,8 @@ function ChatRoomPage() { historyResponses: [], }); + const [isConnected, setIsConnected] = useState(false); + const { queryKey, queryFn } = bookClubs.myJoined({ order: 'DESC' }); const { data } = useQuery({ queryKey, @@ -44,6 +48,47 @@ function ChatRoomPage() { ); useEffect(() => { + const connectSocket = async () => { + const token = getCookie('auth_token'); + if (token) { + try { + const client = await initializeSocket(token); + + let attempts = 0; + const maxAttempts = 50; + + await new Promise((resolve, reject) => { + const checkConnection = setInterval(() => { + console.log(`소켓 연결 시도 ${attempts + 1}회`); + + if (client?.connected) { + console.log('소켓 연결 성공!'); + clearInterval(checkConnection); + resolve(true); + } + + attempts++; + if (attempts >= maxAttempts) { + console.log('소켓 연결 최대 시도 횟수 초과'); + clearInterval(checkConnection); + reject(new Error('소켓 연결 타임아웃')); + } + }, 100); + }); + + setIsConnected(true); + } catch (error) { + console.error('소켓 연결 실패:', error); + } + } + }; + + connectSocket(); + }, []); + + useEffect(() => { + if (!isConnected) return; + const loadChatHistory = async () => { try { const history = await getChatHistory(Number(chatId)); @@ -87,9 +132,9 @@ function ChatRoomPage() { const subscription = subscribeToChat(Number(chatId), handleNewMessage); return () => { - subscription.unsubscribe(); + subscription?.unsubscribe(); }; - }, [chatId]); + }, [chatId, isConnected]); const handleMessageChange = (e: React.ChangeEvent) => { setMessage(e.target.value); diff --git a/src/features/chat/container/ChatContainer.tsx b/src/features/chat/container/ChatContainer.tsx index a9c7ed98..94621d3e 100644 --- a/src/features/chat/container/ChatContainer.tsx +++ b/src/features/chat/container/ChatContainer.tsx @@ -1,14 +1,31 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import Tab from '@/components/tab/Tab'; import { CONTENT_TABS, ContentTab } from '@/constants'; import BookClubChatContainer from './BookClubChatContainer'; import ExchangeChatContainer from './ExchangeChatContainer'; +import { initializeSocket } from '../utils/socket'; +import { getCookie } from '@/features/auth/utils/cookies'; export default function ChatContainer() { const [selectedTab, setSelectedTab] = useState(CONTENT_TABS[0]); + useEffect(() => { + const token = getCookie('auth_token'); + if (token) { + const connectSocket = async () => { + try { + await initializeSocket(token); + console.log('채팅 페이지에서 소켓 재연결 성공'); + } catch (error) { + console.error('채팅 페이지에서 소켓 재연결 실패:', error); + } + }; + connectSocket(); + } + }, []); + return ( <>
diff --git a/src/features/chat/utils/socket.ts b/src/features/chat/utils/socket.ts index 880c744c..de1d5cb4 100644 --- a/src/features/chat/utils/socket.ts +++ b/src/features/chat/utils/socket.ts @@ -26,6 +26,11 @@ export interface ChatHistoryResponse { } export const initializeSocket = async (token: string) => { + if (stompClient?.connected) { + console.log('이미 연결된 소켓이 있습니다.'); + return stompClient; + } + try { const response = await apiClient.get('/book-clubs/my-joined', { params: { @@ -71,6 +76,7 @@ export const initializeSocket = async (token: string) => { return client; } catch (error) { console.error('모임 정보 조회 실패:', error); + throw error; } }; @@ -185,3 +191,7 @@ export const subscribeToChat = ( callback(chatMessage); }); }; + +export const isSocketConnected = () => { + return stompClient?.connected ?? false; +};