Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions src/app/message/chat/[roomId]/ChatRoomPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,9 +28,11 @@ const ChatRoomPage = ({ accessToken, roomId, userId }: IProps) => {
const [isUserListOpen, setIsUserListOpen] = useState(false);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);

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,
Expand All @@ -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(<Toast type='info'>채팅방에서 추방당했어요.</Toast>);
return;
}
console.log('새 메시지:', message);
setChatMessages((prev) => [...prev, message]);
},
});

Expand Down Expand Up @@ -79,6 +82,20 @@ const ChatRoomPage = ({ accessToken, roomId, userId }: IProps) => {
});
}, [chatMessages.length]);

if (isError) {
const status = error?.status;

if (status === 403) {
run(<Toast type='info'>채팅방에 입장할 수 없습니다.</Toast>);
router.replace('/');
return null;
}

if (status === 404) {
return <NotFound />;
}
}

return (
<div className='relative h-[calc(100vh-112px)] overflow-hidden'>
{/* 채팅 화면 */}
Expand Down Expand Up @@ -117,6 +134,7 @@ const ChatRoomPage = ({ accessToken, roomId, userId }: IProps) => {
<UserList
roomId={roomId}
roomType={chatInfo?.chatType as 'DM' | 'GROUP'}
userId={userId}
onClose={() => setIsUserListOpen(false)}
/>
</div>
Expand Down
20 changes: 12 additions & 8 deletions src/components/pages/chat/chat-user-list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className='bg-mono-white flex h-[calc(100vh-112px)] flex-col'>
{/* 헤더 */}
Expand All @@ -32,19 +37,19 @@ export const UserList = ({ onClose, roomId, roomType }: UserListProps) => {
<span className='text-gray-800'>참여자</span>
<span className='text-mint-500'>{data?.totalCount}</span>
</span>
{roomType === 'GROUP' ? (
{roomType === 'GROUP' && isCurrentUserOwner ? (
<button
className='text-text-sm-semibold cursor-pointer'
onClick={() => setIsManaging(!isManaging)}
>
{isManaging ? (
<span className='text-gray-600'>완료</span>
<span className='w-6 text-gray-600'>완료</span>
) : (
<span className='text-mint-600'>관리</span>
<span className='text-mint-600 w-6'>관리</span>
)}
</button>
) : (
<div></div>
<div className='w-6'></div>
)}
</div>

Expand All @@ -70,8 +75,7 @@ export const UserList = ({ onClose, roomId, roomType }: UserListProps) => {
</div>
</div>

{/* 방장이 0번째로 들어온다면 이렇게, 방장이라는걸 알 수 있는 필드가 있다면 수정 */}
{roomType === 'GROUP' && index === 0 ? (
{roomType === 'GROUP' && user.isOwner ? (
<span className='bg-mint-100 text-mint-700 text-text-xs-medium rounded-full px-2.5 py-1'>
방장
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.';
Expand All @@ -24,7 +25,9 @@ const renderWithQueryClient = async (component: React.ReactElement) => {
await act(async () => {
renderResult = render(
<QueryClientProvider client={testQueryClient}>
<ModalProvider>{component}</ModalProvider>
<ToastProvider>
<ModalProvider>{component}</ModalProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
Expand All @@ -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,
});
});

Expand All @@ -62,9 +64,7 @@ describe('FollowingModal 테스트', () => {
});

test('Enter 키 입력 시 폼이 제출된다', async () => {
mockMutate.mockImplementation((_data, options) => {
options?.onSuccess?.();
});
mockMutateAsync.mockResolvedValue(undefined);

await renderWithQueryClient(<FollowingModal userId={mockUserId} />);

Expand All @@ -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' });
});
});
});
36 changes: 16 additions & 20 deletions src/components/pages/message/message-following-modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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(<Toast type='success'>{nickname}님을 팔로우 했어요!</Toast>);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.log(error);
setErrorMessage(error.detail.slice(4));
}
},
});

return (
<ModalContent className='max-w-77.75'>
<ModalTitle className='mb-3'>팔로우 할 닉네임을 입력하세요</ModalTitle>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.';

Expand All @@ -19,9 +20,11 @@ const renderComponent = async () => {
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<ModalProvider>
<FollowingSearch userId={0} />
</ModalProvider>
<ToastProvider>
<ModalProvider>
<FollowingSearch userId={0} />
</ModalProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
Expand Down
1 change: 1 addition & 0 deletions src/hooks/use-chat/use-chat-detail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const useGetChatRoom = (roomId: number) => {
const query = useQuery({
queryKey: ['chatRoom', roomId],
queryFn: () => API.chatService.getChatRoom({ roomId }),
retry: false,
});
return query;
};
60 changes: 47 additions & 13 deletions src/hooks/use-chat/use-chat-socket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@ export const useChatSocket = ({
roomId,
userId,
accessToken,
enabled = true,
onMessage,
onNotification,
}: UseChatSocketOptions) => {
Expand All @@ -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: {
Expand All @@ -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 = () => {
Expand All @@ -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],
Expand Down
Loading