Files
vexplor/backend-node/src/socket/messengerSocket.ts
syc0123 403e5cae40 [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>
2026-04-01 12:20:43 +09:00

172 lines
5.7 KiB
TypeScript

import { Server, Socket } from 'socket.io';
import jwt from 'jsonwebtoken';
import config from '../config/environment';
import { messengerService } from '../services/messengerService';
import { JwtPayload } from '../types/auth';
interface AuthenticatedSocket extends Socket {
data: {
userId: string;
userName: string;
companyCode: string;
};
}
// 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) => {
const token = socket.handshake.auth?.token || socket.handshake.query?.token;
if (!token) {
return next(new Error('Authentication required'));
}
try {
const decoded = jwt.verify(token as string, config.jwt.secret) as JwtPayload;
socket.data.userId = decoded.userId;
socket.data.userName = decoded.userName;
socket.data.companyCode = decoded.companyCode || '';
next();
} catch (error) {
next(new Error('Invalid token'));
}
});
io.on('connection', async (socket: AuthenticatedSocket) => {
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 {
const roomIds = await messengerService.getUserRoomIds(userId, companyCode);
for (const roomId of roomIds) {
socket.join(`${companyCode}:${roomId}`);
}
socket.emit('rooms_joined', { roomIds });
} catch (error) {
console.error('[Messenger] join_rooms error:', error);
socket.emit('error', { message: 'Failed to join rooms' });
}
});
// send_message: save and broadcast
socket.on('send_message', async (data: {
room_id: number;
content: string;
message_type?: string;
parent_message_id?: number;
}) => {
try {
const message = await messengerService.sendMessage(
data.room_id,
userId,
companyCode,
data.content,
data.message_type || 'text',
data.parent_message_id
);
io.to(`${companyCode}:${data.room_id}`).emit('new_message', message);
} catch (error) {
console.error('[Messenger] send_message error:', error);
socket.emit('error', { message: 'Failed to send message' });
}
});
// message_read: update last_read_at
socket.on('message_read', async (data: { room_id: number }) => {
try {
await messengerService.markAsRead(data.room_id, userId);
io.to(`${companyCode}:${data.room_id}`).emit('user_read', {
room_id: data.room_id,
user_id: userId,
read_at: new Date().toISOString(),
});
} catch (error) {
console.error('[Messenger] message_read error:', error);
}
});
// typing indicators
socket.on('typing_start', (data: { room_id: number }) => {
socket.to(`${companyCode}:${data.room_id}`).emit('user_typing', {
room_id: data.room_id,
user_id: userId,
user_name: socket.data.userName,
});
});
socket.on('typing_stop', (data: { room_id: number }) => {
socket.to(`${companyCode}:${data.room_id}`).emit('user_stop_typing', {
room_id: data.room_id,
user_id: userId,
user_name: socket.data.userName,
});
});
// reactions
socket.on('add_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => {
try {
await messengerService.addReaction(data.message_id, userId, data.emoji);
io.to(`${companyCode}:${data.room_id}`).emit('reaction_added', {
message_id: data.message_id,
user_id: userId,
emoji: data.emoji,
});
} catch (error) {
console.error('[Messenger] add_reaction error:', error);
}
});
socket.on('remove_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => {
try {
await messengerService.removeReaction(data.message_id, userId, data.emoji);
io.to(`${companyCode}:${data.room_id}`).emit('reaction_removed', {
message_id: data.message_id,
user_id: userId,
emoji: data.emoji,
});
} catch (error) {
console.error('[Messenger] remove_reaction error:', error);
}
});
// join a specific room (e.g., after creating a new room)
socket.on('join_room', (data: { room_id: number }) => {
socket.join(`${companyCode}:${data.room_id}`);
});
socket.on('disconnect', () => {
console.log(`[Messenger] User disconnected: ${userId}`);
presenceStore.delete(userId);
io.to(presenceRoom).emit('user_status', { userId, status: 'offline' });
});
});
}