Skip to content
Open
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
385 changes: 384 additions & 1 deletion lawmon/package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions lawmon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.2.0",
"sockjs-client": "^1.6.1",
"stompjs": "^2.3.3",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/sockjs-client": "^1.5.4",
"@types/stompjs": "^2.3.9",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3",
Expand Down
17 changes: 17 additions & 0 deletions lawmon/src/pages/chat/ChatRoom.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,20 @@
cursor: pointer;
transition: background-color 0.3s;
}
.connection-status {
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
background-color: #f3f4f6;
font-weight: 500;
}

.connection-status.connected {
color: #059669;
background-color: #d1fae5;
}

.connection-status.disconnected {
color: #dc2626;
background-color: #fee2e2;
}
264 changes: 195 additions & 69 deletions lawmon/src/pages/chat/ChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,19 @@ import './ChatRoom.css';
import ChattingComponent from './ChattingComponent';
import UserImage from '/src/assets/윾건이형.png';
import send from '../../assets/로우몬제출이모티콘.svg';
import { useParams } from 'react-router-dom';
import { useParams, useLocation } from 'react-router-dom';
import SockJS from 'sockjs-client';
import Stomp, { Client } from 'stompjs';
import { useContractStore } from 'shared/store/store';

// 메시지 인터페이스 정의
interface ChatMessage {
type: 'ENTER' | 'TALK' | 'LEAVE';
roomId: string;
sender: string;
message: string;
timestamp: string;
}

//Chatting 인터페이스 정의
interface Chatting {
Expand All @@ -16,47 +28,151 @@ interface Chatting {
}

function Chat() {
const { roomName } = useParams();
const location = useLocation();
const { roomId } = location.state || {};

// Zustand store에서 memberId 가져오기
const memberId = useContractStore((state) => state.memberId);

// WebSocket 관련 상태
const [ws, setWs] = useState<Client | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [reconnectCount, setReconnectCount] = useState(0);

//chattings 상태 변수와 setChattings 함수 정의
const [chattings, setChattings] = useState<Chatting[]>([
{
id: 1,
nickname: '양준석(팀장)',
profileImage: UserImage,
chatting: `안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. 안녕하세요 프론트엔드 팀원 여러분,. `,
time: '17:06',
isMe: false,
},
{
id: 2,
nickname: '아무개',
profileImage: UserImage,
chatting: `신규 개발 중인 개인정보 수정 탭의 사이드 탭의 UI 개발 을 맡고
있는 해당 팀원 분들은 저에게 진척 사항 공유 부탁드립니다~.`,
time: '17:07',
isMe: true,
},
{
id: 3,
nickname: '김민수',
profileImage: UserImage,
chatting: `저랑 이현빈 팀원이 개발 중에 있습니다! 진척 상황 노션에
정리하여 곧 공유드리겠습니다!`,
time: '17:08',
isMe: false,
},
{
id: 4,
nickname: '아무게',
profileImage: UserImage,
chatting: `신규 개발 중인 개인정보 수정 탭의 사이드 탭의 UI 개발 을 맡고
있는 해당 팀원 분들은 저에게 진척 사항 공유 부탁드립니다~ `,
time: '17:09',
isMe: true,
},
]);
const [chattings, setChattings] = useState<Chatting[]>([]);

/* textarea 값 input으로 정의 */
const [input, setInput] = useState('');
const chatEndRef = useRef<HTMLDivElement>(null);

// WebSocket 연결 함수 - 타입 오류 수정
const connect = () => {
if (!roomId || !memberId) {
console.log('Missing roomId or memberId', { roomId, memberId });
return;
}

console.log('Attempting to connect to WebSocket...');
const sock = new SockJS('http://localhost:8080/ws-stomp');
const stompClient = Stomp.over(sock);

// 타입 오류 해결: any로 캐스팅하거나 정확한 타입 사용
stompClient.connect(
{}, // 빈 헤더 객체
(frame?: any) => { // frame을 optional로 처리
console.log('Connected: ', frame);
setIsConnected(true);
setReconnectCount(0); // 연결 성공 시 재연결 카운트 리셋

// 채팅방 구독
stompClient.subscribe(`/sub/chat/room/${roomId}`, (message) => {
console.log('>>> RECEIVED MESSAGE:', message);
console.log('>>> MESSAGE BODY:', message.body);

try {
const recv: ChatMessage = JSON.parse(message.body);
console.log('>>> PARSED MESSAGE:', recv);

// 받은 메시지를 Chatting 형태로 변환
const newChatting: Chatting = {
id: Date.now(),
nickname: recv.sender,
profileImage: UserImage,
chatting: recv.message,
time: new Date(recv.timestamp).toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit'
}),
isMe: recv.sender === String(memberId)
};

// ENTER, LEAVE 메시지는 시스템 메시지로 처리
if (recv.type === 'ENTER' || recv.type === 'LEAVE') {
if (recv.message) {
setChattings(prev => {
const updated = [...prev, {
...newChatting,
nickname: '[알림]',
isMe: false
}];
console.log('>>> UPDATED CHATTINGS (SYSTEM):', updated);
return updated;
});
}
} else if (recv.type === 'TALK') {
setChattings(prev => {
const updated = [...prev, newChatting];
console.log('>>> UPDATED CHATTINGS (TALK):', updated);
return updated;
});
}
} catch (error) {
console.error('>>> MESSAGE PARSE ERROR:', error);
}
});

// 입장 메시지 전송
stompClient.send("/pub/chat/message", {}, JSON.stringify({
type: 'ENTER',
roomId: roomId,
sender: String(memberId),
message: `${memberId}님이 입장했습니다.`,
timestamp: new Date().toISOString()
}));

setWs(stompClient);
},
(error?: any) => { // error도 optional로 처리
console.error('WebSocket connection error:', error);
setIsConnected(false);

// 재연결 횟수 제한 (최대 3회)
if (reconnectCount < 3) {
setTimeout(() => {
console.log(`Attempting to reconnect... (${reconnectCount + 1}/3)`);
setReconnectCount(prev => prev + 1);
connect();
}, 5000);
} else {
console.log('Max reconnection attempts reached. Please check backend server.');
}
}
);
};

// 컴포넌트 마운트 시 WebSocket 연결
useEffect(() => {
connect();

// 컴포넌트 언마운트 시 연결 해제
return () => {
if (ws && isConnected) {
// 퇴장 메시지 전송
try {
ws.send("/pub/chat/message", {}, JSON.stringify({
type: 'LEAVE',
roomId: roomId,
sender: String(memberId),
message: `${memberId}님이 퇴장했습니다.`,
timestamp: new Date().toISOString()
}));

// disconnect 호출 시 타입 오류 방지
if (ws && typeof ws.disconnect === 'function') {
ws.disconnect(() => {
console.log('Disconnected');
});
}
} catch (error) {
console.error('Error during disconnect:', error);
}
}
};
}, [roomId, memberId]);

// 채팅 자동 스크롤
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chattings]);
Expand All @@ -71,44 +187,50 @@ function Chat() {
handleSendMessage();
}
};
// const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// if (e.key === 'Enter' && !e.shiftKey) {
// e.preventDefault();
// handleSendMessage();
// }
// };

/* sendMessage 함수*/
const handleSendMessage = () => {
if (input.trim()) {
const currentInput = input;
if (input.trim() && ws && isConnected) {
const messageData: ChatMessage = {
type: 'TALK',
roomId: roomId,
sender: String(memberId), // number를 string으로 변환
message: input.trim(),
timestamp: new Date().toISOString()
};

console.log('>>> SENDING MESSAGE:', messageData);

// WebSocket으로 메시지 전송
ws.send("/pub/chat/message", {}, JSON.stringify(messageData));

// 임시: 내가 보낸 메시지 즉시 화면에 추가 (백엔드 문제 해결 전까지)
const newChatting: Chatting = {
id: Date.now(),
nickname: String(memberId),
profileImage: UserImage,
chatting: input.trim(),
time: new Date().toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit'
}),
isMe: true
};

setChattings(prev => [...prev, newChatting]);
setInput('');
setChattings((prevMessages) => {
//이전 메세지 배열이 비어 있는지 확인하고, 비어 있지 않으면 마지막 메세지의 id를 가져옴
const newId =
prevMessages.length > 0
? prevMessages[prevMessages.length - 1].id + 1
: 1;
return [
...prevMessages,
{
id: newId,
nickname: '아무게',
profileImage: 'me',
chatting: currentInput,
time: new Date().toLocaleTimeString(),
isMe: true,
},
];
});
// console.log(chattings);
}
};

return (
<>
<div className="flex justify-between items-center title mb-[10px]">
<h1 className="Law-title text-3xl font-bold text-blue-900 mb-4">LAWMON</h1>
<h1 className="Law-title text-3xl font-bold text-blue-900 mb-4">
LAWMON - {roomName}
</h1>
<div className={`connection-status ${isConnected ? 'connected' : 'disconnected'}`}>
{isConnected ? '🟢 연결됨' : '🔴 연결 끊김'}
</div>
</div>
<div className="flex Chat-container">
<div
Expand Down Expand Up @@ -141,13 +263,17 @@ function Chat() {
<textarea
className="w-full h-12 mx-3 focus:outline-none resize-none flex-1 chatting-input"
style={{ resize: 'none' }}
placeholder="Ask Anything..."
placeholder={isConnected ? "메시지를 입력하세요..." : "연결 중..."}
onKeyDown={handleKeyDown}
// onKeyUp={handleKeyUp}
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={!isConnected}
/>
<button className="chatting-button" onClick={handleSendMessage}>
<button
className={`chatting-button ${!isConnected ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleSendMessage}
disabled={!isConnected}
>
<img src={send} alt="SendBtn" />
</button>
</div>
Expand Down
Loading