diff --git a/src/app/message/chat/[roomId]/ChatRoomPage.tsx b/src/app/message/chat/[roomId]/ChatRoomPage.tsx index 8d5ac356..ec56aaac 100644 --- a/src/app/message/chat/[roomId]/ChatRoomPage.tsx +++ b/src/app/message/chat/[roomId]/ChatRoomPage.tsx @@ -4,8 +4,11 @@ import { useRouter } from 'next/navigation'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import NotFound from '@/app/not-found'; import { ChatHeader, ChatInput, MyChat, OtherChat } from '@/components/pages/chat'; import { UserList } from '@/components/pages/chat/chat-user-list'; +import { Toast } from '@/components/ui'; +import { useToast } from '@/components/ui/toast/core'; import { useChatSocket, useGetChatMessages, @@ -25,9 +28,11 @@ const ChatRoomPage = ({ accessToken, roomId, userId }: IProps) => { const [isUserListOpen, setIsUserListOpen] = useState(false); const [chatMessages, setChatMessages] = useState([]); - const { data: chatInfo } = useGetChatRoom(roomId); + const { data: chatInfo, error, isError } = useGetChatRoom(roomId); const { data: previousMessages } = useGetChatMessages(roomId); const { mutate: readMessages } = useReadMessages(roomId, userId); + const { run } = useToast(); + const { messages: newMessages, sendMessage, @@ -36,17 +41,15 @@ const ChatRoomPage = ({ accessToken, roomId, userId }: IProps) => { roomId, userId, accessToken, + enabled: !!chatInfo, onMessage: (message) => { - console.log('새 메시지:', message); - setChatMessages((prev) => [...prev, message]); - }, - // 백엔드 로직 확인 필요.(동작 X) - onNotification: (notification) => { - console.log(notification); - if (notification.type === 'KICKED' && notification.chatRoomId === roomId) { - alert('채팅방에서 추방되었습니다.'); + if (message.messageType === 'KICK' && message.targetUserId === userId) { router.replace('/'); + run(채팅방에서 추방당했어요.); + return; } + console.log('새 메시지:', message); + setChatMessages((prev) => [...prev, message]); }, }); @@ -79,6 +82,20 @@ const ChatRoomPage = ({ accessToken, roomId, userId }: IProps) => { }); }, [chatMessages.length]); + if (isError) { + const status = error?.status; + + if (status === 403) { + run(채팅방에 입장할 수 없습니다.); + router.replace('/'); + return null; + } + + if (status === 404) { + return ; + } + } + return (
{/* 채팅 화면 */} @@ -117,6 +134,7 @@ const ChatRoomPage = ({ accessToken, roomId, userId }: IProps) => { setIsUserListOpen(false)} />
diff --git a/src/components/pages/chat/chat-user-list/index.tsx b/src/components/pages/chat/chat-user-list/index.tsx index 4bb24fba..f8e79059 100644 --- a/src/components/pages/chat/chat-user-list/index.tsx +++ b/src/components/pages/chat/chat-user-list/index.tsx @@ -16,13 +16,18 @@ interface UserListProps { onClose: () => void; roomId: number; roomType: 'DM' | 'GROUP'; + userId: number; } -export const UserList = ({ onClose, roomId, roomType }: UserListProps) => { +export const UserList = ({ onClose, roomId, roomType, userId }: UserListProps) => { const [isManaging, setIsManaging] = useState(false); const { open } = useModal(); const { data } = useGetParticipants(roomId); - console.log(roomType); + + const isCurrentUserOwner = data?.participants.some( + (participant) => participant.userId === userId && participant.isOwner, + ); + return (
{/* 헤더 */} @@ -32,19 +37,19 @@ export const UserList = ({ onClose, roomId, roomType }: UserListProps) => { 참여자 {data?.totalCount} - {roomType === 'GROUP' ? ( + {roomType === 'GROUP' && isCurrentUserOwner ? ( ) : ( -
+
)}
@@ -70,8 +75,7 @@ export const UserList = ({ onClose, roomId, roomType }: UserListProps) => { - {/* 방장이 0번째로 들어온다면 이렇게, 방장이라는걸 알 수 있는 필드가 있다면 수정 */} - {roomType === 'GROUP' && index === 0 ? ( + {roomType === 'GROUP' && user.isOwner ? ( 방장 diff --git a/src/components/pages/message/message-following-modal/index.test.tsx b/src/components/pages/message/message-following-modal/index.test.tsx index 8ffa4ade..0679890f 100644 --- a/src/components/pages/message/message-following-modal/index.test.tsx +++ b/src/components/pages/message/message-following-modal/index.test.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ModalProvider } from '@/components/ui'; +import { ToastProvider } from '@/components/ui/toast/core'; import { useAddFollowers } from '@/hooks/use-follower'; import { FollowingModal } from '.'; @@ -24,7 +25,9 @@ const renderWithQueryClient = async (component: React.ReactElement) => { await act(async () => { renderResult = render( - {component} + + {component} + , ); }); @@ -33,15 +36,14 @@ const renderWithQueryClient = async (component: React.ReactElement) => { }; describe('FollowingModal 테스트', () => { - const mockMutate = jest.fn(); + const mockMutateAsync = jest.fn(); const mockUserId = 123; beforeEach(() => { jest.clearAllMocks(); - // 기본 mock 설정 (useAddFollowers as jest.Mock).mockReturnValue({ - mutate: mockMutate, + mutateAsync: mockMutateAsync, }); }); @@ -62,9 +64,7 @@ describe('FollowingModal 테스트', () => { }); test('Enter 키 입력 시 폼이 제출된다', async () => { - mockMutate.mockImplementation((_data, options) => { - options?.onSuccess?.(); - }); + mockMutateAsync.mockResolvedValue(undefined); await renderWithQueryClient(); @@ -73,7 +73,7 @@ describe('FollowingModal 테스트', () => { fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); await waitFor(() => { - expect(mockMutate).toHaveBeenCalledWith({ followNickname: 'test' }, expect.any(Object)); + expect(mockMutateAsync).toHaveBeenCalledWith({ followNickname: 'test' }); }); }); }); diff --git a/src/components/pages/message/message-following-modal/index.tsx b/src/components/pages/message/message-following-modal/index.tsx index 77d4c49f..82ab9cac 100644 --- a/src/components/pages/message/message-following-modal/index.tsx +++ b/src/components/pages/message/message-following-modal/index.tsx @@ -3,36 +3,32 @@ import { useState } from 'react'; import { useForm } from '@tanstack/react-form'; import { Icon } from '@/components/icon'; -import { Button, Input, ModalContent, ModalTitle, useModal } from '@/components/ui'; +import { Button, Input, ModalContent, ModalTitle, Toast, useModal } from '@/components/ui'; +import { useToast } from '@/components/ui/toast/core'; import { useAddFollowers } from '@/hooks/use-follower'; export const FollowingModal = ({ userId }: { userId: number }) => { const { close } = useModal(); const [errorMessage, setErrorMessage] = useState(null); - const { mutate: addFollower } = useAddFollowers({ userId }); + const { mutateAsync } = useAddFollowers({ userId }); + const { run } = useToast(); const form = useForm({ - defaultValues: { - nickname: '', - }, - onSubmit: ({ value }) => { + defaultValues: { nickname: '' }, + onSubmit: async ({ value }) => { const { nickname } = value; setErrorMessage(null); - - addFollower( - { - followNickname: nickname, - }, - { - onSuccess: () => { - close(); - }, - onError: (e) => { - setErrorMessage(e.detail.slice(4)); - }, - }, - ); + try { + await mutateAsync({ followNickname: nickname }); + close(); + run({nickname}님을 팔로우 했어요!); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + console.log(error); + setErrorMessage(error.detail.slice(4)); + } }, }); + return ( 팔로우 할 닉네임을 입력하세요 diff --git a/src/components/pages/message/message-following-search/index.test.tsx b/src/components/pages/message/message-following-search/index.test.tsx index 598af25c..d3068904 100644 --- a/src/components/pages/message/message-following-search/index.test.tsx +++ b/src/components/pages/message/message-following-search/index.test.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { ModalProvider } from '@/components/ui'; +import { ToastProvider } from '@/components/ui/toast/core'; import { FollowingSearch } from '.'; @@ -19,9 +20,11 @@ const renderComponent = async () => { await act(async () => { render( - - - + + + + + , ); }); diff --git a/src/hooks/use-chat/use-chat-detail/index.ts b/src/hooks/use-chat/use-chat-detail/index.ts index e78a0376..b9d9641b 100644 --- a/src/hooks/use-chat/use-chat-detail/index.ts +++ b/src/hooks/use-chat/use-chat-detail/index.ts @@ -6,6 +6,7 @@ export const useGetChatRoom = (roomId: number) => { const query = useQuery({ queryKey: ['chatRoom', roomId], queryFn: () => API.chatService.getChatRoom({ roomId }), + retry: false, }); return query; }; diff --git a/src/hooks/use-chat/use-chat-socket/index.ts b/src/hooks/use-chat/use-chat-socket/index.ts index 9314be0d..d4b7cc61 100644 --- a/src/hooks/use-chat/use-chat-socket/index.ts +++ b/src/hooks/use-chat/use-chat-socket/index.ts @@ -8,6 +8,7 @@ import { ChatMessage } from '@/types/service/chat'; interface UseChatSocketOptions { roomId: number; userId: number; + enabled?: boolean; accessToken: string | null; onMessage?: (message: ChatMessage) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -18,6 +19,7 @@ export const useChatSocket = ({ roomId, userId, accessToken, + enabled = true, onMessage, onNotification, }: UseChatSocketOptions) => { @@ -37,12 +39,11 @@ export const useChatSocket = ({ }, [onNotification]); useEffect(() => { - if (!accessToken) return; + if (!accessToken || !enabled) return; + const client = new Client({ webSocketFactory: () => { - const socket = new SockJS(`${process.env.NEXT_PUBLIC_API_BASE_URL}/ws-chat`, null, { - transports: ['websocket'], // WebSocket만 사용 - }); + const socket = new SockJS(`${process.env.NEXT_PUBLIC_API_BASE_URL}/ws-chat`, null, {}); return socket; }, connectHeaders: { @@ -60,16 +61,26 @@ export const useChatSocket = ({ // 채팅방 구독 client.subscribe(`/sub/chat/room/${roomId}`, (message: IMessage) => { - const payload: ChatMessage = JSON.parse(message.body); - setMessages((prev) => [...prev, payload]); - onMessageRef.current?.(payload); + try { + const payload: ChatMessage = JSON.parse(message.body); + setMessages((prev) => [...prev, payload]); + onMessageRef.current?.(payload); + } catch (error) { + console.error('Failed to parse chat message:', error); + } }); // 개인 알림 구독 client.subscribe(`/sub/user/${userId}`, (message: IMessage) => { - const payload = JSON.parse(message.body); - onNotificationRef.current?.(payload); + try { + const payload = JSON.parse(message.body); + onNotificationRef.current?.(payload); + } catch (error) { + console.error('Failed to parse notification:', error); + } }); + + clientRef.current = client; }; client.onDisconnect = () => { @@ -79,26 +90,49 @@ export const useChatSocket = ({ client.onStompError = (frame) => { console.error('STOMP error:', frame); + setIsConnected(false); + }; + + client.onWebSocketError = (event) => { + console.error('WebSocket error:', event); + setIsConnected(false); }; client.activate(); clientRef.current = client; return () => { - client.deactivate(); + if (clientRef.current) { + // 연결이 활성화되어 있을 때만 deactivate + if (clientRef.current.active) { + clientRef.current.deactivate(); + } + clientRef.current = null; + } + setIsConnected(false); + setMessages([]); // 메시지 초기화 }; - }, [roomId, userId, accessToken]); + }, [roomId, userId, accessToken, enabled]); const sendMessage = useCallback( (content: string) => { - if (clientRef.current?.connected) { + if (!clientRef.current?.connected) { + console.log('WebSocket is not connected'); + return false; + } + + try { clientRef.current.publish({ destination: '/pub/chat/message', body: JSON.stringify({ chatRoomId: roomId, - content, + content: content.trim(), }), }); + return true; + } catch (error) { + console.error('Failed to send message:', error); + return false; } }, [roomId], diff --git a/src/hooks/use-follower/use-follower-add/index.ts b/src/hooks/use-follower/use-follower-add/index.ts index 8bded4a7..b57d2534 100644 --- a/src/hooks/use-follower/use-follower-add/index.ts +++ b/src/hooks/use-follower/use-follower-add/index.ts @@ -15,8 +15,9 @@ export const useAddFollowers = ( queryClient.invalidateQueries({ queryKey: ['followers', userId] }); console.log('팔로워 추가 성공'); }, - onError: () => { + onError: (error) => { console.log('팔로워 추가 실패'); + throw error; }, }); return query; diff --git a/src/types/service/chat.ts b/src/types/service/chat.ts index e19bb403..4d11ae22 100644 --- a/src/types/service/chat.ts +++ b/src/types/service/chat.ts @@ -20,9 +20,10 @@ export interface ChatMessage { senderName: string | null; senderProfileImage: string | null; content: string; - messageType: 'TEXT' | 'SYSTEM'; + messageType: 'TEXT' | 'SYSTEM' | 'KICK'; timestamp?: string; createdAt?: string; + targetUserId: number; } export interface ChatUser { @@ -33,6 +34,7 @@ export interface ChatUser { status: 'ACTIVE' | 'INACTIVE'; // 확인 필요💥💥 userId: number; profileMessage: string; + isOwner: boolean; } export interface GetChatRoomsResponse {