- 사용자 온라인 상태 표시 (온라인/자리비움/오프라인) 디스코드 스타일 - 채팅방별 알림 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>
172 lines
5.7 KiB
TypeScript
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' });
|
|
});
|
|
});
|
|
}
|