[RAPID] 메신저 기능 구현 및 UI/UX 개선

- 사용자 온라인 상태 표시 (온라인/자리비움/오프라인) 디스코드 스타일
- 채팅방별 알림 ON/OFF 토글 (Bell 아이콘, localStorage 저장)
- 파일 업로드 실시간 소켓 브로드캐스트 및 한글 파일명 깨짐 수정
- FAB 읽지않음 배지 버그 수정 (메신저 닫혀있을 때 markAsRead 차단)
- 타이핑 도트 애니메이션, 날짜 오늘/어제 표시
- 입력창 bordered box, DM 편집 버튼 숨김
- 메신저 설정 버튼 제거, 새 대화 시작하기 Empty state CTA
- useMessengerSocket 소켓 중복 생성 방지 (MessengerModal로 이동)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

[RAPID-micro] 추적 파일 정리 및 메신저 소소한 변경

- .omc/state/ 파일 git 추적 제거 (.gitignore 이미 설정됨)
- db/checkpoints/ gitignore 추가
- globals.css: 메신저 메시지 시간 폰트 스타일 추가
- useMessenger.ts: fileMimeType 필드 및 API_BASE_URL import 추가

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 12:13:00 +09:00
parent 3661b469cd
commit 403e5cae40
21 changed files with 440 additions and 759 deletions

View File

@@ -112,14 +112,19 @@ class MessengerController {
return res.status(400).json({ success: false, message: 'room_id is required.' });
}
const io = getIo();
const savedFiles = [];
for (const file of files) {
// Use a readable placeholder as content to avoid filename encoding issues
const isImage = file.mimetype.startsWith('image/');
const content = isImage ? '[이미지]' : '[파일]';
// Create a file message
const message = await messengerService.sendMessage(
roomId,
user.userId,
user.companyCode!,
file.originalname,
content,
'file'
);
@@ -131,6 +136,11 @@ class MessengerController {
mimeType: file.mimetype,
});
message.files = [savedFile];
// Broadcast to room so recipients receive it in real-time
io.to(`${user.companyCode}:${roomId}`).emit('new_message', message);
savedFiles.push({ message, file: savedFile });
}

View File

@@ -12,6 +12,9 @@ interface AuthenticatedSocket extends Socket {
};
}
// In-memory presence store: userId → { companyCode, status }
const presenceStore = new Map<string, { companyCode: string; status: 'online' | 'away' }>();
export function initMessengerSocket(io: Server) {
// JWT authentication middleware
io.use((socket, next) => {
@@ -35,6 +38,30 @@ export function initMessengerSocket(io: Server) {
const { userId, companyCode } = socket.data;
console.log(`[Messenger] User connected: ${userId}`);
// Join company presence room and broadcast online status
const presenceRoom = `${companyCode}:presence`;
socket.join(presenceRoom);
presenceStore.set(userId, { companyCode, status: 'online' });
socket.to(presenceRoom).emit('user_status', { userId, status: 'online' });
// Send current online users list to newly connected socket
const currentPresence: Record<string, string> = {};
for (const [uid, info] of presenceStore.entries()) {
if (info.companyCode === companyCode) {
currentPresence[uid] = info.status;
}
}
socket.emit('presence_list', currentPresence);
// set_status: client emits when tab focus changes
socket.on('set_status', (data: { status: 'online' | 'away' }) => {
const entry = presenceStore.get(userId);
if (entry) {
entry.status = data.status;
io.to(presenceRoom).emit('user_status', { userId, status: data.status });
}
});
// join_rooms: subscribe to all user's rooms
socket.on('join_rooms', async () => {
try {
@@ -99,6 +126,7 @@ export function initMessengerSocket(io: Server) {
socket.to(`${companyCode}:${data.room_id}`).emit('user_stop_typing', {
room_id: data.room_id,
user_id: userId,
user_name: socket.data.userName,
});
});
@@ -136,6 +164,8 @@ export function initMessengerSocket(io: Server) {
socket.on('disconnect', () => {
console.log(`[Messenger] User disconnected: ${userId}`);
presenceStore.delete(userId);
io.to(presenceRoom).emit('user_status', { userId, status: 'offline' });
});
});
}