pathname : “/chat-room”
InboxCard, LinboxCard, MessageCard에서 router.push로 접근이 가능하다
type === “TICKET”인 경우 신고가되서나온 고객센터 채팅방이다
{
"court_id": 31,
"created_at": "2025-11-26T13:26:41.296Z",
"messages": {"1110": [Object]},
"participants": {"center_id": 67, "user_id": 60},
"slot_id": 4155,
"status": "ACTIVE",
"type": "NORMAL"
}
type === “NORMAL”인 데이터이며 일반 채팅방으로 보인다
{
"court_id": 31,
"created_at": "2025-11-26T13:26:41.296Z",
"messages": [Object],
"participants": [Object],
"slot_id": 4155,
"status": "ACTIVE",
"type": "NORMAL"
}
# ChatRoom 컴포넌트 코드 분석
## 📋 개요
`chat-room.tsx`는 사용자와 코트 관리자 간의 실시간 채팅 기능을 제공하는 화면입니다. Firebase Realtime Database를 사용하여 실시간 메시지 동기화를 구현하고 있습니다.
---
## 🎯 주요 기능
### 1. **실시간 채팅**
- Firebase Realtime Database의 `onValue` 리스너를 통해 실시간 메시지 수신
- 메시지 자동 스크롤 및 읽음 처리
### 2. **예약 정보 표시**
- `BOOKING` 상태: 일반 예약 정보 표시 (`ScheduleCard`)
- `LOCK` 상태: 잠금된 스케줄 정보 표시 (`LScheduleCard`)
### 3. **신고 기능**
- 채팅방 신고 기능 (`handleReportChatRoom`)
- 만료 시간 기준으로 신고 가능 여부 판단
### 4. **티켓 시스템**
- `TICKET` 타입일 경우 이슈 신고 화면으로 전환
- 티켓 열기/닫기 상태 관리
### 5. **키보드 처리**
- iOS/Android 플랫폼별 키보드 애니메이션 처리
- 키보드 높이에 따른 레이아웃 자동 조정
---
## 📦 의존성 및 Import
### 외부 라이브러리
- `react-native`: 기본 컴포넌트 및 API
- `swr`: 서버 데이터 페칭 (예약 정보)
- `firebase/database`: 실시간 데이터베이스
- `expo-router`: 네비게이션
- `lodash`: 유틸리티 함수
### 내부 컴포넌트
- `InboxItem`: 개별 메시지 아이템
- `InboxInput`: 메시지 입력 컴포넌트
- `ScheduleCard` / `LScheduleCard`: 예약 정보 카드
- `Header`: 상단 헤더
---
## 🔑 핵심 State 관리
```typescript
// UI 상태
const [canReport, setCanReport] = useState(false); // 신고 가능 여부
const [sendLoading, setSendLoading] = useState(false); // 메시지 전송 로딩
const [reportLoading, setReportLoading] = useState(false); // 신고 로딩
const [openingTicket, setOpeningTicket] = useState<boolean>; // 티켓 열림 상태
// 레이아웃 관련
const [scheduleCardHeight, setScheduleCardHeight] = useState(0); // 스케줄 카드 높이
const [chatInputHeight, setChatInputHeight] = useState<number>(0); // 입력창 높이
const [keyboardHeight, setKeyboardHeight] = useState(0); // 키보드 높이
// 메시지 데이터
const [fetchedMessages, setfetchedMessages] = useState<Message[]>([]); // Firebase에서 받은 메시지
const [shouldScrollToEnd, setShouldScrollToEnd] = useState(false); // 자동 스크롤 플래그
// BOOKING 상태일 때
const { data: schedule, mutate: fetchBookings } = useSWR(
isFocused && state === "BOOKING" ? `/bookings/by-id/${slot_id}` : null,
fetcher
);
// LOCK 상태일 때
const { data: lockedSchedule, mutate: fetchLocked } = useSWR(
isFocused && state === "LOCK" ? `/schedules/locked-schedules-by-id/${lock_id}` : null,
fetcher
);
useEffect(() => {
const setupDatabaseListener = async () => {
await ensureFirebaseAuth();
const chatRoomRef = ref(database, `chatrooms/${chatRoom_id}/messages`);
return onValue(chatRoomRef, async (snapshot) => {
if (snapshot.exists()) {
const data = snapshot.val();
// 각 메시지에 isOwn 속성 추가
const messages = await Promise.all(
Object.values(data).map(async (message) => ({
...message,
isOwn: await isOwnMessage(message.human_sender_id),
}))
);
setfetchedMessages(messages);
}
});
};
setupDatabaseListener();
}, []);
주요 포인트:
isOwnMessage로 메시지 소유자 판단Promise.all로 비동기 처리View (container)
├── Stack.Screen (Header)
│ ├── 일반 채팅: 신고 버튼 포함
│ └── TICKET: 이슈 신고 타이틀만
│
└── View (container)
├── Animated.View (messageList)
│ ├── ScheduleCard / LScheduleCard (예약 정보)
│ └── FlatList (메시지 리스트)
│ ├── ListEmptyComponent (빈 상태)
│ ├── renderItem (InboxItem)
│ └── ListFooterComponent
│ ├── SendingMessageIndicator
│ ├── ReportedCard
│ ├── ReportTicketButton
│ └── 만료/취소 메시지
│
└── Animated.View (chatContainer - 고정 위치)
└── InboxInput (메시지 입력창)
// iOS: keyboardWillShow/Hide (키보드 애니메이션과 동기화)
// Android: keyboardDidShow/Hide (키보드 표시 후 처리)
const keyboardWillShowListener = Keyboard.addListener(
Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow",
(e) => {
const newKeyboardHeight =
e.endCoordinates.height - (Platform.OS === "ios" ? insets.bottom : 0);
// Animated API로 부드러운 애니메이션
Animated.timing(keyboardAnimValue, {
toValue: newKeyboardHeight,
duration: Platform.OS === "ios" ? e.duration || 250 : 250,
}).start();
}
);
적용 위치:
chatContainer의 bottom 속성에 keyboardAnimValue 적용messageList의 paddingBottom에 키보드 높이 + 입력창 높이 추가handleReportChatRoom - 채팅방 신고const handleReportChatRoom = () => {
if (!canReport) { // 만료 시간이 지나지 않았을 때만 신고 가능
// API 호출
// 성공 시: type을 "TICKET"으로 변경하여 화면 전환
// 실패 시: 이미 신고되었다고 처리 (에러 핸들링 특이사항)
}
};
특이사항:
canReport가 true면 신고 불가 (만료 시간이 지나면 신고 불가)isOwnMessage - 메시지 소유자 판단const isOwnMessage = async (sender_id: number) => {
const manager_id = await AsyncStorage.getItem(StorageKeys.UserId);
return String(sender_id) === String(manager_id);
};
용도:
getDescription - 취소 메시지 포맷팅const getDescription = () => {
switch (schedule?.data?.Status?.code_name) {
case "cancelled":
return `${i18n.t("booking.description.canceled_by_me")} ${formatedCancelDate}`;
case "cancelled_by_manager":
return `${i18n.t("booking.description.canceled_by_court")} ${formatedCancelDate}`;
}
};
state에 따라 BOOKING 또는 LOCK 데이터 페칭expired_at과 현재 시간 비교slot_id 또는 lock_id 변경 시 재계산// 현재 코드 (문제)
useEffect(() => {
const setupDatabaseListener = async () => {
const chatRoomRef = ref(database, `chatrooms/${chatRoom_id}/messages`);
return onValue(chatRoomRef, ...); // 반환하지만 사용하지 않음
};
setupDatabaseListener();
}, []); // cleanup 함수 없음
개선안:
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const setupDatabaseListener = async () => {
await ensureFirebaseAuth();
const chatRoomRef = ref(database, `chatrooms/${chatRoom_id}/messages`);
unsubscribe = onValue(chatRoomRef, ...);
};
setupDatabaseListener();
return () => {
if (unsubscribe) unsubscribe();
};
}, [chatRoom_id]);
useLocalSearchParams()의 반환값이 배열일 수 있음 (530줄)type이 배열인 경우 처리 필요type !== "TICKET"type === "TICKET"// 만료 시간이 지나지 않았을 때만 신고 가능
setCanReport(currentTime.isAfter(expired_at));
// canReport가 false일 때 신고 가능 (역논리)
disabled={
type === "TICKET"
? !openingTicket // 티켓이 닫혀있으면 비활성화
: canReport // 만료 시간이 지나면 비활성화
}
data={_.sortBy(fetchedMessages, ["sent_at"])}
lodash의 sortBy로 메시지 시간순 정렬onContentSizeChange={() => {
if (shouldScrollToEnd) {
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
setShouldScrollToEnd(false);
}, 100);
}
}}
setTimeout으로 레이아웃 계산 대기onLayout={(event) => {
setScheduleCardHeight(event.nativeEvent.layout.height);
}}
InboxInputsendLoading 상태 관리InboxItemisOwn에 따른 스타일 분기ScheduleCard / LScheduleCard{
type: "TICKET" | 일반 채팅
state: "BOOKING" | "LOCK"
status: "OPEN" | "CLOSED"
slot_id: number (BOOKING일 때)
lock_id: number (LOCK일 때)
datetime: string
center_id: number
created_at: string
expired_at: string
chatRoom_id: string
}
chatContainer: {
position: "absolute",
bottom: keyboardAnimValue, // 키보드 높이에 따라 동적 조정
borderTopWidth: 1,
shadow: {...} // 상단 그림자 효과
}
paddingBottom: Animated.add(keyboardAnimValue, chatInputHeight)
useLocalSearchParams 타입 명시chatrooms/{chatRoom_id}/messagesGET /bookings/by-id/{slot_id}GET /schedules/locked-schedules-by-id/{lock_id}POST /chats/report/chat_roomPUT /chats/mark-read/{chatRoom_id}