diff --git a/src/App.tsx b/src/App.tsx index a0555da..4616a24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import MbtiTestIntro from "@/pages/MbtiTestIntro"; import MbtiTestQuestions from "@/pages/MbtiTestQuestions"; import MbtiTestResult from "@/pages/MbtiTestResult"; import Error from "@/pages/Error"; + import CenteredLayout from "@/components/CenteredLayout"; import ToastMessage from "@/components/ToastMessage"; import useAuthStore from "@/store/useAuthStore"; @@ -145,6 +146,7 @@ const App = () => { } /> } /> } /> + } /> diff --git a/src/api/openChat.ts b/src/api/openChat.ts new file mode 100644 index 0000000..593467f --- /dev/null +++ b/src/api/openChat.ts @@ -0,0 +1,101 @@ +import { authInstance } from "./axios"; +import { + OpenChatRoom, + OpenChatMessage, + CreateOpenChatRequest, + OpenChatRoomsResponse, + CreateOpenChatResponse +} from "@/types/openChat"; + +/** + * 오픈 채팅방 목록 조회 + */ +export const getOpenChatRooms = async (): Promise => { + try { + const response = + await authInstance.get("/api/open-chat"); + return response.data.data; + } catch (error) { + console.error("Failed to fetch open chat rooms:", error); + throw new Error("오픈 채팅방 목록을 불러올 수 없습니다."); + } +}; + +/** + * 오픈 채팅방 메시지 조회 + * @param openChatId - 오픈 채팅방 ID + * @param openChatMessageId - 마지막 메시지 번호 (선택사항) + */ +export const getOpenChatMessages = async ( + openChatId: number, + openChatMessageId?: number +): Promise<{ messages: OpenChatMessage[]; hasMore: boolean }> => { + try { + const params = openChatMessageId + ? `?openChatMessageId=${openChatMessageId}` + : ""; + const response = await authInstance.get<{ data: OpenChatMessage[] }>( + `/api/open-chat/${openChatId}${params}` + ); + + // API 응답 데이터 검증 - 실제 API 구조에 맞게 수정 + if ( + response.data && + response.data.data && + Array.isArray(response.data.data) + ) { + return { + messages: response.data.data, + hasMore: false // 일단 false로 설정, 필요시 서버에서 제공 + }; + } else { + console.warn("API 응답 데이터가 예상 형식과 다름:", response.data); + return { messages: [], hasMore: false }; + } + } catch (error) { + console.error("Failed to fetch open chat messages:", error); + // 오류 발생 시 빈 데이터 반환 (앱이 중단되지 않도록) + return { messages: [], hasMore: false }; + } +}; + +/** + * 오픈 채팅방 생성 + * @param chatData - 채팅방 생성 데이터 + */ +export const createOpenChatRoom = async ( + chatData: CreateOpenChatRequest +): Promise<{ openChatId: number }> => { + try { + const response = await authInstance.post( + "/api/open-chat", + chatData + ); + return response.data.data; + } catch (error) { + console.error("Failed to create open chat room:", error); + throw new Error("오픈 채팅방을 생성할 수 없습니다."); + } +}; + +/** + * 오픈 채팅방 상세 정보 조회 + * @param openChatId - 오픈 채팅방 ID + */ +export const getOpenChatRoomDetail = async ( + openChatId: number +): Promise => { + try { + const rooms = await getOpenChatRooms(); + const room = rooms.find((room) => room.id === openChatId); + + if (!room) { + throw new Error("채팅방을 찾을 수 없습니다."); + } + + return room; + } catch (error) { + console.error("Failed to fetch open chat room detail:", error); + throw new Error("채팅방 정보를 불러올 수 없습니다."); + } +}; diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 579a0eb..d580e7f 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -13,6 +13,7 @@ interface ProfileProps { chatTitle: string; description: string; image: string; + openChatId?: number; }; } const Profile = ({ @@ -55,7 +56,8 @@ const Profile = ({ state: { type: "topicChat", chatTitle: topicData.chatTitle, - description: topicData.description + description: topicData.description, + openChatId: topicData.openChatId || 1 } }); } diff --git a/src/components/TopicProfileContainer.tsx b/src/components/TopicProfileContainer.tsx index c76d331..3266864 100644 --- a/src/components/TopicProfileContainer.tsx +++ b/src/components/TopicProfileContainer.tsx @@ -1,25 +1,82 @@ +import { useEffect, useState } from "react"; import Profile from "@/components/Profile"; +import { getOpenChatRooms } from "@/api/openChat"; +import { OpenChatRoom } from "@/types/openChat"; type TopicData = { chatTitle: string; description: string; image: string; + openChatId: number; }; -const topicData: TopicData[] = [ - { - chatTitle: "N의 대화", - description: "망상력 N% 대화방", - image: "/image/N의_대화.svg" - }, - { - chatTitle: "F의 대화", - description: "F 감성 대화방", - image: "/image/F의_대화.svg" +const TopicProfileContainer = () => { + const [topicData, setTopicData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadOpenChatRooms = async () => { + try { + const rooms = await getOpenChatRooms(); + const convertedData: TopicData[] = rooms.map((room: OpenChatRoom) => ({ + chatTitle: room.title, + description: room.description, + image: room.imageUrl || "/image/N의_대화.svg", + openChatId: room.id + })); + + if (convertedData.length !== 0) { + setTopicData(convertedData); + } else { + setTopicData([ + { + chatTitle: "N의 대화", + description: "망상력 N% 대화방", + image: "/image/N의_대화.svg", + openChatId: 1 + }, + { + chatTitle: "F의 대화", + description: "F 감성 대화방", + image: "/image/F의_대화.svg", + openChatId: 2 + } + ]); + } + } catch (error) { + console.error("Failed to load open chat rooms:", error); + // 오류 시 기본값 사용 + setTopicData([ + { + chatTitle: "N의 대화", + description: "망상력 N% 대화방", + image: "/image/N의_대화.svg", + openChatId: 1 + }, + { + chatTitle: "F의 대화", + description: "F 감성 대화방", + image: "/image/F의_대화.svg", + openChatId: 2 + } + ]); + } finally { + setIsLoading(false); + } + }; + + loadOpenChatRooms(); + }, []); + + if (isLoading) { + return ( +
+
+
+
+ ); } -]; -const TopicProfileContainer = () => { return (
{topicData.map((topic, index) => ( diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index f50b39a..3cf27e7 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, ChangeEvent, KeyboardEvent } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { Helmet } from "react-helmet"; import { authInstance } from "@/api/axios"; import { trackEvent } from "@/libs/analytics"; @@ -9,10 +9,17 @@ import ChatMessage from "@/components/ChatMessage"; import ChatActionBar from "@/components/ChatActionBar"; import TipsMenuContainer from "@/components/tips/TipsMenuContainer"; import pickMbtiImage from "@/utils/pickMbtiImage"; +import websocketService from "@/services/websocket"; +import { getOpenChatMessages } from "@/api/openChat"; +import { WebSocketMessage } from "@/types/openChat"; +import { Mbti } from "@/types/mbti"; interface Message { role: "user" | "assistant"; content: string; + nickname?: string; + mbti?: string; + messageType?: "text" | "image" | "system"; } interface ChatResponse { @@ -29,25 +36,138 @@ interface ChatHistoryResponse { } const Chat = () => { + const navigate = useNavigate(); const { state } = useLocation(); + const { mbti, mode, id = Date.now().toString(), name, - chatTitle: openChatTitle + chatTitle: openChatTitle, + openChatId, + nickname, + description } = state; const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isOpen, setIsOpen] = useState(false); + const [isConnected, setIsConnected] = useState(false); const bottomRef = useRef(null); + const wsCleanupRef = useRef<{ + messageCleanup?: () => void; + connectionCleanup?: () => void; + }>({}); const chatTitle = openChatTitle || (name ? `${name}과 대화` : `${mbti}와 대화`); const assistantImgUrl = pickMbtiImage(mbti); const storageKey = `chatMessages_${id}`; + const isTopicChat = mode === "topicChat"; + + useEffect(() => { + if (!isTopicChat) { + return; + } + + // topicChat 유효성 검증 + if (!openChatId || !nickname || !mbti) { + navigate("/"); + return; + } + + const initializeOpenChat = async () => { + try { + // 기존 메시지 로드 + try { + const response = await getOpenChatMessages(openChatId); + + if ( + response && + response.messages && + Array.isArray(response.messages) + ) { + const convertedMessages: Message[] = response.messages.map( + (msg) => { + return { + role: msg.nickname === nickname ? "user" : "assistant", + content: msg.message, + nickname: msg.nickname, + mbti: msg.mbti || undefined, + messageType: msg.messageType || "text" + }; + } + ); + + // 메시지 순서: API에서 최신순으로 오므로 reverse()로 시간순 정렬 + setMessages(convertedMessages.reverse()); + } else { + setMessages([]); + } + } catch (apiError) { + setMessages([]); + } + + // WebSocket 연결 시도 + const wsUrl = + import.meta.env.VITE_WEBSOCKET_URL || "ws://localhost:8080"; + + try { + const connected = await websocketService.connect({ + nickname, + mbti: mbti as Mbti, + openChatId + }); + + if (connected) { + // 핸들러 등록 시 cleanup 함수들을 저장 + const messageCleanup = websocketService.onMessage( + handleWebSocketMessage + ); + const connectionCleanup = + websocketService.onConnectionChange(setIsConnected); + + // cleanup 함수들을 ref에 저장 + wsCleanupRef.current = { + messageCleanup, + connectionCleanup + }; + + setIsConnected(true); + } else { + setIsConnected(false); + } + } catch (wsError) { + console.warn("WebSocket 연결 실패:", wsError); + setIsConnected(false); + } + } catch (error) { + console.error("오픈채팅 초기화 실패:", error); + } + }; + + initializeOpenChat(); + + return () => { + // 웹소켓 핸들러 정리 + if ( + wsCleanupRef.current.messageCleanup || + wsCleanupRef.current.connectionCleanup + ) { + wsCleanupRef.current.messageCleanup?.(); + wsCleanupRef.current.connectionCleanup?.(); + wsCleanupRef.current = {}; + } + + // 웹소켓 연결 해제 + if (isTopicChat && websocketService.isConnected()) { + websocketService.disconnect(); + } + }; + }, [isTopicChat, openChatId, nickname, mbti, navigate]); + useEffect(() => { const fetchMessages = async () => { if (mode === "virtualFriend") { @@ -67,7 +187,7 @@ const Chat = () => { } catch (error) { console.error("채팅 불러오기 실패", error); } - } else { + } else if (!isTopicChat) { const storedMessage = sessionStorage.getItem(storageKey); if (storedMessage) { setMessages(JSON.parse(storedMessage)); @@ -76,13 +196,13 @@ const Chat = () => { }; fetchMessages(); - }, [mode, id, storageKey]); + }, [mode, id, storageKey, isTopicChat]); useEffect(() => { - if (mode !== "virtualFriend") { + if (mode !== "virtualFriend" && !isTopicChat) { sessionStorage.setItem(storageKey, JSON.stringify(messages)); } - }, [messages, mode, storageKey]); + }, [messages, mode, storageKey, isTopicChat]); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -97,15 +217,116 @@ const Chat = () => { setIsOpen(nextState); }; + const handleWebSocketMessage = (wsMessage: WebSocketMessage) => { + if (wsMessage.type === "ERROR") { + // 에러 메시지 처리 + const errorMessage: Message = { + role: "assistant", + content: wsMessage.message, + messageType: "system" + }; + + // 중복 시스템 메시지 방지 + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.messageType === "system" && + lastMessage.content === wsMessage.message + ) { + return prev; + } + return [...prev, errorMessage]; + }); + } else if (wsMessage.type === "NOTICE") { + // 시스템 알림 메시지 처리 (입장/퇴장) + const systemMessage: Message = { + role: "assistant", + content: wsMessage.message, + messageType: "system" + }; + + // 중복 시스템 메시지 방지 + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.messageType === "system" && + lastMessage.content === wsMessage.message + ) { + return prev; + } + return [...prev, systemMessage]; + }); + } else if ( + wsMessage.type === null && + wsMessage.nickname && + wsMessage.message + ) { + // 일반 채팅 메시지 처리 + const newMessage: Message = { + role: wsMessage.nickname === nickname ? "user" : "assistant", + content: wsMessage.message, + nickname: wsMessage.nickname, + mbti: wsMessage.mbti || undefined, + messageType: "text" + }; + setMessages((prev) => [...prev, newMessage]); + } + }; + const handleSend = async (messageToSend: string) => { if (!messageToSend.trim()) return; + setInput(""); + + if (isTopicChat) { + // 사용자 메시지를 즉시 화면에 표시 + const userMessage: Message = { + role: "user", + content: messageToSend, + nickname, + mbti: mbti as string + }; + setMessages((prev) => [...prev, userMessage]); + + // 오픈채팅 WebSocket 전송 + try { + if (websocketService.isConnected()) { + // 실제 WebSocket으로 메시지 전송 + websocketService.sendMessage(messageToSend.trim()); + } else { + // WebSocket이 연결되지 않은 경우 Mock 응답 + setTimeout(() => { + const mockResponse: Message = { + role: "assistant", + content: `[Mock] ${nickname}님의 메시지를 받았습니다! "${messageToSend}"에 대한 응답입니다.`, + nickname: "시스템", + mbti: "ENFP" + }; + setMessages((prev) => [...prev, mockResponse]); + }, 1000); + } + } catch (error) { + console.error("메시지 전송 실패:", error); + // 오류 발생 시 Mock 응답 + setTimeout(() => { + const errorResponse: Message = { + role: "assistant", + content: `메시지 전송에 실패했습니다. Mock 응답으로 대체합니다.`, + nickname: "시스템", + mbti: "ENFP" + }; + setMessages((prev) => [...prev, errorResponse]); + }, 1000); + } + return; + } + + // 기존 AI 채팅 로직 const updatedMessages: Message[] = [ ...messages, { role: "user", content: messageToSend } ]; setMessages(updatedMessages); - setInput(""); try { const url = @@ -145,38 +366,103 @@ const Chat = () => { return ( <> - - - + + +
+ + {/* topicChat 연결 상태 표시 */} + {isTopicChat && ( +
+ {isConnected ? "실시간 연결됨" : "연결 중..."} +
+ )} +
{/* 메시지 리스트 */} - {messages.map((msg, idx) => ( -
- {/* 캐릭터 아이콘 */} - {msg.role === "assistant" && ( - MBTI ICON - )} - {/* 채팅 메시지 */} -
- + + {messages.map((msg, idx) => { + // 시스템 메시지 처리 + if (msg.messageType === "system") { + return ( +
+ + {msg.content} + +
+ ); + } + + return ( +
+ {/* 캐릭터 아이콘 또는 사용자 정보 */} + {msg.role === "assistant" && ( +
+ {isTopicChat && msg.nickname ? ( +
+
+ {msg.nickname.charAt(0)} +
+ {msg.mbti && ( + + {msg.mbti} + + )} +
+ ) : ( + MBTI ICON + )} +
+ )} + {/* 채팅 메시지 */} +
+ {isTopicChat && msg.role === "assistant" && msg.nickname && ( +
+ {msg.nickname} +
+ )} + +
-
- ))} + ); + })}
diff --git a/src/pages/SelectInfo.tsx b/src/pages/SelectInfo.tsx index 3d866b7..df3a944 100644 --- a/src/pages/SelectInfo.tsx +++ b/src/pages/SelectInfo.tsx @@ -7,6 +7,8 @@ import { getMBTIgroup, mapAgeToNumber } from "@/utils/helpers"; import { authInstance } from "@/api/axios"; import ToastMessage from "@/components/ToastMessage"; import trackClickEvent from "@/utils/trackClickEvent"; +import { Mbti } from "@/types/mbti"; +import websocketService from "@/services/websocket"; type FastFriendResponse = { header: { @@ -43,11 +45,17 @@ function isVirtualFriendResponse( const SelectInfo = () => { const navigate = useNavigate(); const location = useLocation(); - const { type, mbti: testResultMBTI, chatTitle, description } = location.state; // type: fastFriend, virtualFriend, topicChat + const { + type, + mbti: testResultMBTI, + chatTitle, + description, + openChatId + } = location.state; // type: fastFriend, virtualFriend, topicChat const isFastFriend = type === "fastFriend"; const isVirtualFriend = type === "virtualFriend"; const isTopicChat = type === "topicChat"; - const isNameRequired = isVirtualFriend; + const isNameRequired = isVirtualFriend || isTopicChat; const headerTitle = isTopicChat ? "내 정보입력" @@ -82,6 +90,7 @@ const SelectInfo = () => { const [job, setJob] = useState(null); const [freeSetting, setFreeSetting] = useState(""); const [toastMessage, setToastMessage] = useState(null); + const [isCheckingNickname, setIsCheckingNickname] = useState(false); useEffect(() => { if (mbtiTestResult && mbtiTestResult.length === 4) { @@ -142,27 +151,93 @@ const SelectInfo = () => { setTimeout(() => setToastMessage(null), 3000); }; + const checkNicknameAvailability = async ( + nicknameToCheck: string + ): Promise => { + if (!openChatId) return true; + + // 환경 변수로 WebSocket 사용 여부 체크 + const useWebSocketServer = + import.meta.env.VITE_USE_WEBSOCKET_SERVER !== "false"; + + if (!useWebSocketServer) { + console.log("🔧 WebSocket 서버 사용 안함 (환경 변수), Mock 모드 사용"); + await new Promise((resolve) => setTimeout(resolve, 800)); + console.log( + `[MOCK] Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` + ); + return Math.random() > 0.3; // 70% 확률로 사용 가능 + } + + try { + // 현재 선택된 MBTI 조합 생성 + const mbti = + `${selectedMBTI.E}${selectedMBTI.N}${selectedMBTI.F}${selectedMBTI.P}` as Mbti; + + console.log("🔍 WebSocket 닉네임 검사 시작:", { + nicknameToCheck, + openChatId, + mbti + }); + + // WebSocket 닉네임 중복 검사 (서버 준비 시 활성화) + return await websocketService.checkNickname( + nicknameToCheck, + openChatId, + mbti + ); + } catch (error) { + console.warn( + "WebSocket nickname check failed, using mock:", + (error as Error).message + ); + + // WebSocket 서버가 준비되지 않았거나 연결 실패 시 Mock 구현으로 fallback + await new Promise((resolve) => setTimeout(resolve, 800)); + console.log( + `[MOCK] Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` + ); + return Math.random() > 0.3; // 70% 확률로 사용 가능 + } + }; + const handleConfirmButton = async () => { const isMBTIComplete = Object.values(selectedMBTI).every( (val) => val !== null ); - // topicChat일 때는 이름만 필수 + // topicChat일 때 처리 if (isTopicChat) { - if (!name) { - return showToast("이름을 입력해주세요"); + if (!name.trim()) { + return showToast("닉네임을 입력해주세요"); } - // topicChat은 바로 채팅으로 이동 + + if (!isMBTIComplete) { + return showToast("MBTI를 선택해주세요"); + } + + // 닉네임 중복 검사 + setIsCheckingNickname(true); + const isNicknameAvailable = await checkNicknameAvailability(name.trim()); + setIsCheckingNickname(false); + + if (!isNicknameAvailable) { + return showToast("같은 닉네임을 가진 유저가 있어요!"); + } + + // 오픈 채팅방으로 이동 + const mbti = + `${selectedMBTI.E}${selectedMBTI.N}${selectedMBTI.F}${selectedMBTI.P}` as Mbti; trackClickEvent("오픈채팅 - 내 정보 입력", "대화 시작하기"); navigate("/chat", { - // FIXME: 추후 수정 필요 (오픈 채팅 기능) state: { - mbti: "ENFP", // 기본 MBTI 또는 선택된 MBTI mode: "topicChat", - id: Date.now().toString(), - name, + mbti, + id: openChatId.toString(), chatTitle, - description + description, + openChatId, + nickname: name.trim() } }); return; @@ -405,6 +480,7 @@ const SelectInfo = () => { className="text-2lg leading-[24px] font-bold tracking-[0em] text-gray-600" > 이름 + * { {/* 대화 시작 버튼 */}
diff --git a/src/services/websocket.ts b/src/services/websocket.ts new file mode 100644 index 0000000..d1ccd63 --- /dev/null +++ b/src/services/websocket.ts @@ -0,0 +1,230 @@ +import { WebSocketMessage, WebSocketRequestMessage } from "@/types/openChat"; +import { Mbti } from "@/types/mbti"; + +export interface WebSocketConfig { + nickname: string; + mbti: Mbti; + openChatId: number; +} + +export class OpenChatWebSocket { + private ws: WebSocket | null = null; + private config: WebSocketConfig | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + private messageHandlers = new Set<(message: WebSocketMessage) => void>(); + private connectionHandlers = new Set<(connected: boolean) => void>(); + + constructor(private serverUrl: string) {} + + connect(config: WebSocketConfig): Promise { + return new Promise((resolve, reject) => { + // 기존 연결이 있으면 먼저 정리 + if (this.ws && this.ws.readyState !== WebSocket.CLOSED) { + this.disconnect(); + } + + this.config = config; + + const wsUrl = `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(config.nickname)}&mbti=${config.mbti}&open_chat_id=${config.openChatId}`; + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.reconnectAttempts = 0; + this.notifyConnectionHandlers(true); + resolve(true); + }; + + this.ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + this.notifyMessageHandlers(message); + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + this.ws.onclose = (event) => { + this.notifyConnectionHandlers(false); + + if (!event.wasClean && this.shouldReconnect()) { + this.scheduleReconnect(); + } + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + reject(new Error("Failed to connect to chat server")); + }; + } catch (error) { + reject(error); + } + }); + } + + disconnect() { + if (this.ws) { + this.ws.close(1000, "User disconnected"); + this.ws = null; + } + + // 모든 핸들러 정리 + this.messageHandlers.clear(); + this.connectionHandlers.clear(); + + this.config = null; + this.reconnectAttempts = 0; + } + + sendMessage(content: string) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket is not connected"); + } + + if (!this.config) { + throw new Error("WebSocket config is not set"); + } + + const message: WebSocketRequestMessage = { + type: "MESSAGE", + mbti: this.config.mbti, + nickname: this.config.nickname, + message: content, + openChatId: this.config.openChatId + }; + + this.ws.send(JSON.stringify(message)); + } + + checkNickname( + nickname: string, + openChatId: number, + mbti: Mbti = "ENFP" + ): Promise { + return new Promise((resolve, reject) => { + // config가 없어도 닉네임 체크는 가능하도록 기본값 사용 + const useMbti = this.config?.mbti || mbti; + const wsUrl = `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${useMbti}&open_chat_id=${openChatId}`; + + const tempWs = new WebSocket(wsUrl); + + tempWs.onopen = () => { + // 서버에 닉네임 체크 요청 메시지 전송 + const payload: WebSocketRequestMessage = { + type: "NICKNAME_CHECK", + mbti: useMbti, + nickname: nickname, + message: "", + openChatId: openChatId + }; + + tempWs.send(JSON.stringify(payload)); + }; + + tempWs.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + + // 닉네임 중복시 ERROR 타입으로 응답 + if ( + message.type === "ERROR" && + message.message.includes("닉네임이 중복됩니다") + ) { + resolve(false); + } else if (message.type === "ERROR") { + resolve(false); + } else { + resolve(true); + } + + tempWs.close(); + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + + reject(error); + tempWs.close(); + } + }; + + tempWs.onerror = (error) => { + console.error("Failed to check nickname:", error); + + reject(new Error("Failed to check nickname")); + }; + + tempWs.onclose = (event) => { + console.log("WebSocket closed:", event.code, event.reason); + }; + }); + } + + onMessage(handler: (message: WebSocketMessage) => void) { + this.messageHandlers.add(handler); + + return () => { + this.messageHandlers.delete(handler); + }; + } + + onConnectionChange(handler: (connected: boolean) => void) { + this.connectionHandlers.add(handler); + + return () => { + this.connectionHandlers.delete(handler); + }; + } + + isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + + private shouldReconnect(): boolean { + return ( + this.reconnectAttempts < this.maxReconnectAttempts && this.config !== null + ); + } + + private scheduleReconnect() { + setTimeout( + () => { + if (this.config && this.shouldReconnect()) { + this.reconnectAttempts++; + console.log( + `Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})` + ); + this.connect(this.config); + } + }, + this.reconnectDelay * Math.pow(2, this.reconnectAttempts) + ); + } + + private notifyMessageHandlers(message: WebSocketMessage) { + this.messageHandlers.forEach((handler) => { + try { + handler(message); + } catch (error) { + console.error("Error in message handler:", error); + } + }); + } + + private notifyConnectionHandlers(connected: boolean) { + this.connectionHandlers.forEach((handler) => { + try { + handler(connected); + } catch (error) { + console.error("Error in connection handler:", error); + } + }); + } +} + +const websocketService = new OpenChatWebSocket( + import.meta.env.VITE_WEBSOCKET_URL || "ws://localhost:8080" +); + +export default websocketService; diff --git a/src/types/openChat.ts b/src/types/openChat.ts new file mode 100644 index 0000000..2542d99 --- /dev/null +++ b/src/types/openChat.ts @@ -0,0 +1,79 @@ +export interface OpenChatRoom { + id: number; + title: string; + description: string; + imageUrl?: string; + participantCount: number; + maxParticipants?: number; + createdAt: string; + updatedAt: string; +} + +export interface OpenChatMessage { + openChatMessageId: number; + openChatId: number; + nickname: string; + mbti: string | null; + message: string; + timestamp?: string; + messageType?: "text" | "image" | "system"; +} + +export interface ChatParticipant { + nickname: string; + mbti: string; + joinedAt: string; +} + +// 웹소켓 요청 메시지 형태 +export interface WebSocketRequestMessage { + type: string; + mbti: string; + nickname: string; + message: string; + openChatId: number; +} + +// 웹소켓 응답 메시지 형태 +export interface WebSocketMessage { + type: "ERROR" | "NOTICE" | null; + mbti: string | null; + nickname: string | null; + message: string; + openChatId: number; +} + +export interface CreateOpenChatRequest { + title: string; + description: string; + imageUrl?: string; +} + +export interface OpenChatRoomsResponse { + header: { + code: number; + message: string; + }; + data: OpenChatRoom[]; +} + +export interface OpenChatMessagesResponse { + header: { + code: number; + message: string; + }; + data: { + messages: OpenChatMessage[]; + hasMore: boolean; + }; +} + +export interface CreateOpenChatResponse { + header: { + code: number; + message: string; + }; + data: { + openChatId: number; + }; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 225e483..74b17be 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -10,7 +10,8 @@ "@/types/*": ["types/*"], "@/utils/*": ["utils/*"], "@/constants/*": ["constants/*"], - "@/libs/*": ["libs/*"] + "@/libs/*": ["libs/*"], + "@/services/*": ["services/*"] }, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", diff --git a/vite.config.ts b/vite.config.ts index 72847d0..2886227 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,6 +58,10 @@ export default defineConfig(({ mode }: { mode: string }) => { { find: "@/mock", replacement: path.resolve(__dirname, "src/mock") + }, + { + find: "@/services", + replacement: path.resolve(__dirname, "src/services") } ] }