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
886 changes: 507 additions & 379 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@hello-pangea/dnd": "^18.0.1",
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"@stomp/stompjs": "^7.1.1",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.87.4",
"@tanstack/react-table": "^8.21.3",
Expand All @@ -26,6 +27,7 @@
"react-dom": "^18.3.1",
"react-error-boundary": "^6.0.0",
"react-router-dom": "^7.8.2",
"sockjs-client": "^1.6.1",
"recharts": "^3.2.0",
"tailwindcss": "^4.1.12",
"wx-react-gantt": "^1.3.1",
Expand Down
43 changes: 43 additions & 0 deletions src/api/chatApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import api from './client';

// --- ChatRoomController API ---

// 모든 채팅방 목록 가져오기
export const fetchChatRooms = () => api.get('/chat-room/all');

// 특정 채팅방의 메시지 내역 가져오기 (페이지네이션)
export const fetchMessages = (chatRoomId, page = 0, size = 50) =>
api.get(`/chat-room/${chatRoomId}/messages`, { params: { page, size } });

// 채팅방 생성
export const createChatRoom = (memberIdList, roomName, type) =>
api.post('/chat-room/create', { memberIdList, name: roomName, type });

// 채팅방에 사용자 초대
export const inviteToChatRoom = (chatRoomId, memberIdList) =>
api.post(`/chat-room/${chatRoomId}/invite`, { memberIdList });

// 이미 존재하는 채팅방에 멤버 추가
export const addMembersToExistingChatRoom = (chatRoomId, memberIdList) =>
api.post(`/chat-room/${chatRoomId}/add-members`, { chatRoomId, memberIdList });

// 메시지 삭제
export const deleteMessage = (messageId) => api.delete(`/chat-room/messages/${messageId}`);

// 채팅방 퇴장
export const leaveChatRoom = (chatRoomId) => api.delete(`/chat-room/delete/${chatRoomId}`);

// 전체 안 읽은 메시지 개수 조회
export const fetchTotalUnreadCount = () => api.get('/chat-room/total-unread-count');

// 특정 채팅방 메시지 모두 읽음 처리
export const markAllAsRead = (chatRoomId, lastReadMessageId) =>
api.get(`/chat-room/${chatRoomId}/mark-all-as-read`, { params: { lastReadMessageId } });

// --- MemberProfileController API ---

// 모든 사용자 프로필 조회
export const fetchAllMembers = () => api.get('/profile/all');

// 이름 또는 이메일로 사용자 검색
export const searchMembers = (keyword) => api.get('/profile/search', { params: { keyword } });
2 changes: 1 addition & 1 deletion src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ api.interceptors.response.use(
}
);

export default api;
export default api;
94 changes: 94 additions & 0 deletions src/api/socketService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

let stompClient = null;
let subscriptions = new Map();

// WebSocket 연결
export const connectWebSocket = (onConnected) => {
if (stompClient && stompClient.active) {
console.log('WebSocket is already connected.');
if (onConnected) onConnected();
return;
}

stompClient = new Client({
webSocketFactory: () => new SockJS('http://localhost:8081/ws/chat'), // 백엔드 포트 8081로 수정
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
onConnect: () => {
console.log('WebSocket connected!');
// 연결 성공 후, 콜백 실행
if (onConnected) onConnected();
},
onStompError: (frame) => {
console.error('Broker reported error: ' + frame.headers['message']);
console.error('Additional details: ' + frame.body);
},
// 연결 끊김 시 재연결 시도
onDisconnect: (frame) => {
console.log('WebSocket disconnected:', frame);
// 필요한 경우 재연결 로직 추가
},
});
stompClient.activate();
};

// WebSocket 연결 해제
export const disconnectWebSocket = () => {
if (stompClient) {
stompClient.deactivate();
stompClient = null;
subscriptions.clear();
console.log('WebSocket disconnected!');
}
};

export const subscribe = (destination, callback) => {
if (!stompClient || !stompClient.active) {
console.error('STOMP client is not connected. Cannot subscribe to ', destination);
return null;
}
// 동일한 주소에 대한 중복 구독 방지 (기존 구독 해지 후 재구독)
if (subscriptions.has(destination)) {
subscriptions.get(destination).unsubscribe();
subscriptions.delete(destination);
}

const subscription = stompClient.subscribe(destination, (message) => {
callback(JSON.parse(message.body));
});
subscriptions.set(destination, subscription);
return subscription;
}

// 특정 채팅방 토픽 구독
export const subscribeToChatRoom = (chatRoomId, onMessageReceived) => {
return subscribe(`/topic/chatroom/${chatRoomId}`, onMessageReceived);
};

// 개인 알림 큐 구독
export const subscribeToUserQueue = (queueName, onNotificationReceived) => {
return subscribe(`/user/queue/${queueName}`, onNotificationReceived);
};

// 구독 해지
export const unsubscribe = (destination) => {
if (subscriptions.has(destination)) {
subscriptions.get(destination).unsubscribe();
subscriptions.delete(destination);
}
}

// 메시지 전송 (발행)
export const sendMessage = (chatMessage) => {
if (stompClient && stompClient.active) {
stompClient.publish({
destination: '/app/send',
body: JSON.stringify(chatMessage),
});
} else {
console.error('Cannot send message, STOMP client is not connected.');
}
};
2 changes: 1 addition & 1 deletion src/components/common/chat/ChatHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const ChatHeader = ({
isMinimized,
onBackToList,
onToggleMinimize,
onToggleChat
onToggleChat,
}) => {
const { user } = useAuth();

Expand Down
120 changes: 74 additions & 46 deletions src/components/common/chat/ChatRoom.jsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,49 @@
import { useEffect, useRef } from 'react'
import { Send } from '@mui/icons-material'
import useChatStore from '../../../store/chatStore'
import { useAuth } from '../../../hooks/useAuth'
import { useEffect, useRef } from 'react';
import { Send } from '@mui/icons-material';
import useChatStore from '../../../store/chatStore';

const ChatRoom = ({
selectedChat,
message,
onMessageChange,
onSendMessage
const ChatRoom = ({
selectedChat,
message,
onMessageChange,
onSendMessage
}) => {
const messagesEndRef = useRef(null)
const { user } = useAuth()

// Zustand store에서 메시지 가져오기
const { getMessages } = useChatStore()
const messages = getMessages(selectedChat?.id || 0)
const messagesEndRef = useRef(null);

// 새 메시지가 추가될 때마다 스크롤을 맨 아래로
const { getMessages, connectWebSocket, disconnectWebSocket, markAsRead, currentUser } = useChatStore();
const messages = [...getMessages(selectedChat?.id || 0)].sort((a, b) => {
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});

// WebSocket 연결 및 구독 관리
useEffect(() => {
if (selectedChat?.id && currentUser?.id) {
connectWebSocket(currentUser.id, selectedChat.id);

// 채팅방 진입 시 마지막 메시지를 읽음 처리
const lastMessage = messages[messages.length - 1];
if (lastMessage && lastMessage.senderId !== currentUser.id) { // 내가 보낸 메시지가 아닐 경우
markAsRead(selectedChat.id, lastMessage.id);
}
}

return () => {
disconnectWebSocket();
};
}, [selectedChat?.id, currentUser?.id, connectWebSocket, disconnectWebSocket, markAsRead, messages.length]);

Check warning on line 33 in src/components/common/chat/ChatRoom.jsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has a missing dependency: 'messages'. Either include it or remove the dependency array

// 메시지 스크롤
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);

// 시간 포맷팅
const formatTime = (timestamp) => {
if (!timestamp) return '';
return new Date(timestamp).toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit'
})
}
});
};

return (
<div className="flex flex-col h-full bg-white/50 rounded-b-lg">
Expand All @@ -36,31 +52,42 @@
<div className="space-y-3">
{messages.length === 0 ? (
<div className="text-center text-gray-500 mt-8">
<p>{selectedChat?.name}님과의 채팅을 시작해보세요!</p>
<p>{selectedChat?.name} 와(과)의 채팅을 시작해보세요!</p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.sender === user?.name ? 'justify-end' : 'justify-start'}`}
>
<div className={`p-3 rounded-lg shadow-sm max-w-xs ${
msg.sender === user?.name
? 'bg-emerald-400/50 backdrop-blur-lg text-white'
: 'bg-white'
}`}>
{msg.sender !== user?.name && (
<p className="text-xs text-gray-600 mb-1 font-medium">{msg.sender}</p>
)}
<p className="text-sm">{msg.text}</p>
<span className={`text-xs ${
msg.sender === user?.name ? 'text-emerald-100' : 'text-gray-500'
}`}>
{formatTime(msg.timestamp)}
</span>
messages.map((msg) => {
const isMine = msg.senderId === currentUser.id;
return (
<div
key={msg.id}
className={`flex ${isMine ? 'justify-end' : 'justify-start'} ${msg.type === 'SYSTEM' ? 'justify-center' : ''}`}
>
<div className={`p-3 rounded-lg shadow-sm max-w-xs ${msg.type === 'SYSTEM'
? 'bg-gray-200 text-gray-700 text-center'
: isMine
? 'bg-emerald-400/50 backdrop-blur-lg text-white'
: 'bg-white text-gray-800'
}`}>
{!isMine && msg.type !== 'DELETED' && msg.type !== 'SYSTEM' && (
<p className="text-xs text-gray-600 mb-1 font-medium">{msg.senderName}</p>
)}
{msg.type === 'DELETED' ? (
<p className="text-sm italic text-gray-500">삭제된 메시지입니다.</p>
) : (
<p className="text-sm">{msg.content}</p>
)}
<div className="flex justify-end items-center gap-1 mt-1">
<span className={`text-xs ${isMine ? 'text-emerald-100' : 'text-gray-500'}`}>
{formatTime(msg.createdAt)}
</span>
{isMine && msg.readCount > 0 && msg.type !== 'DELETED' && (
<span className="text-xs text-emerald-100 font-bold">{msg.readCount}</span>
)}
</div>
</div>
</div>
</div>
))
)
})
)}
<div ref={messagesEndRef} />
</div>
Expand Down Expand Up @@ -88,7 +115,8 @@
</form>
</div>
</div>
)
}
);
};


export default ChatRoom
export default ChatRoom;
Loading