[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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -229,3 +229,4 @@ frontend/test-results/
|
||||
frontend/playwright.config.ts
|
||||
frontend/tests/
|
||||
frontend/test-results/
|
||||
db/checkpoints/
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167}
|
||||
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548}
|
||||
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997}
|
||||
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528}
|
||||
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641}
|
||||
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980}
|
||||
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646}
|
||||
@@ -1,10 +0,0 @@
|
||||
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735}
|
||||
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607}
|
||||
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249}
|
||||
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624}
|
||||
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"lastSentAt": "2026-03-27T04:34:49.003Z"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input_preview": "{\"command\":\"ls /Users/yc/ERP-node/frontend/.env* 2>/dev/null && cat /Users/yc/ERP-node/frontend/.env.local 2>/dev/null\",\"description\":\"Check frontend env files\"}",
|
||||
"error": "Exit code 1\n(eval):1: no matches found: /Users/yc/ERP-node/frontend/.env*",
|
||||
"timestamp": "2026-03-30T09:22:21.149Z",
|
||||
"retry_count": 1
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
{
|
||||
"updatedAt": "2026-03-30T09:22:05.771Z",
|
||||
"missions": [
|
||||
{
|
||||
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
|
||||
"source": "session",
|
||||
"name": "none",
|
||||
"objective": "Session mission",
|
||||
"createdAt": "2026-03-25T00:33:45.197Z",
|
||||
"updatedAt": "2026-03-25T01:37:19.659Z",
|
||||
"status": "done",
|
||||
"workerCount": 5,
|
||||
"taskCounts": {
|
||||
"total": 5,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"inProgress": 0,
|
||||
"completed": 5,
|
||||
"failed": 0
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"name": "Explore:ad233db",
|
||||
"role": "Explore",
|
||||
"ownership": "ad233db7fa6f059dd",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T00:34:44.932Z"
|
||||
},
|
||||
{
|
||||
"name": "Explore:a31a0f7",
|
||||
"role": "Explore",
|
||||
"ownership": "a31a0f729d328643f",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T00:35:24.588Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a9510b7",
|
||||
"role": "executor",
|
||||
"ownership": "a9510b7d8ec5a1ce7",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T00:42:01.730Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a1c1d18",
|
||||
"role": "executor",
|
||||
"ownership": "a1c1d186f0eb6dfc1",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T00:40:12.608Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a9a231d",
|
||||
"role": "executor",
|
||||
"ownership": "a9a231d40fd5a150b",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T01:37:19.659Z"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z",
|
||||
"at": "2026-03-25T00:40:12.608Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a1c1d18",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a1c1d186f0eb6dfc1"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z",
|
||||
"at": "2026-03-25T00:42:01.730Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a9510b7",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a9510b7d8ec5a1ce7"
|
||||
},
|
||||
{
|
||||
"id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z",
|
||||
"at": "2026-03-25T01:35:00.232Z",
|
||||
"kind": "update",
|
||||
"agent": "executor:a9a231d",
|
||||
"detail": "started executor:a9a231d",
|
||||
"sourceKey": "session-start:a9a231d40fd5a150b"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z",
|
||||
"at": "2026-03-25T01:37:19.659Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a9a231d",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a9a231d40fd5a150b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none",
|
||||
"source": "session",
|
||||
"name": "none",
|
||||
"objective": "Session mission",
|
||||
"createdAt": "2026-03-25T04:59:24.101Z",
|
||||
"updatedAt": "2026-03-25T05:06:35.487Z",
|
||||
"status": "done",
|
||||
"workerCount": 7,
|
||||
"taskCounts": {
|
||||
"total": 7,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"inProgress": 0,
|
||||
"completed": 7,
|
||||
"failed": 0
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"name": "executor:a32b34c",
|
||||
"role": "executor",
|
||||
"ownership": "a32b34c341b854da5",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:06:18.081Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:ad2c89c",
|
||||
"role": "executor",
|
||||
"ownership": "ad2c89cf14936ea42",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:02:45.524Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a2c140c",
|
||||
"role": "executor",
|
||||
"ownership": "a2c140c5a5adb0719",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:05:13.388Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a2e5213",
|
||||
"role": "executor",
|
||||
"ownership": "a2e52136ea8f04385",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:03:53.163Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a3735bf",
|
||||
"role": "executor",
|
||||
"ownership": "a3735bf51a74d6fc8",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:01:33.817Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a77742b",
|
||||
"role": "executor",
|
||||
"ownership": "a77742ba65fd2451c",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:06:09.324Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a4eb932",
|
||||
"role": "executor",
|
||||
"ownership": "a4eb932c438b898c0",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:06:35.487Z"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z",
|
||||
"at": "2026-03-25T04:59:43.650Z",
|
||||
"kind": "update",
|
||||
"agent": "executor:a3735bf",
|
||||
"detail": "started executor:a3735bf",
|
||||
"sourceKey": "session-start:a3735bf51a74d6fc8"
|
||||
},
|
||||
{
|
||||
"id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z",
|
||||
"at": "2026-03-25T04:59:48.683Z",
|
||||
"kind": "update",
|
||||
"agent": "executor:a77742b",
|
||||
"detail": "started executor:a77742b",
|
||||
"sourceKey": "session-start:a77742ba65fd2451c"
|
||||
},
|
||||
{
|
||||
"id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z",
|
||||
"at": "2026-03-25T04:59:53.841Z",
|
||||
"kind": "update",
|
||||
"agent": "executor:a4eb932",
|
||||
"detail": "started executor:a4eb932",
|
||||
"sourceKey": "session-start:a4eb932c438b898c0"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z",
|
||||
"at": "2026-03-25T05:01:33.817Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a3735bf",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a3735bf51a74d6fc8"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z",
|
||||
"at": "2026-03-25T05:02:45.524Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:ad2c89c",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:ad2c89cf14936ea42"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z",
|
||||
"at": "2026-03-25T05:03:53.163Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a2e5213",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a2e52136ea8f04385"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z",
|
||||
"at": "2026-03-25T05:05:13.388Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a2c140c",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a2c140c5a5adb0719"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z",
|
||||
"at": "2026-03-25T05:06:09.324Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a77742b",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a77742ba65fd2451c"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z",
|
||||
"at": "2026-03-25T05:06:18.081Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a32b34c",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a32b34c341b854da5"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z",
|
||||
"at": "2026-03-25T05:06:35.487Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a4eb932",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a4eb932c438b898c0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "session:2ea5d668-aa64-4450-a6ac-24143b6e6cee:none",
|
||||
"source": "session",
|
||||
"name": "none",
|
||||
"objective": "Session mission",
|
||||
"createdAt": "2026-03-30T09:18:44.199Z",
|
||||
"updatedAt": "2026-03-30T09:22:05.771Z",
|
||||
"status": "done",
|
||||
"workerCount": 1,
|
||||
"taskCounts": {
|
||||
"total": 1,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"inProgress": 0,
|
||||
"completed": 1,
|
||||
"failed": 0
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"name": "qa-tester:a8c34e4",
|
||||
"role": "qa-tester",
|
||||
"ownership": "a8c34e4ce449d1c4b",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-30T09:22:05.771Z"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"id": "session-start:a8c34e4ce449d1c4b:2026-03-30T09:18:44.199Z",
|
||||
"at": "2026-03-30T09:18:44.199Z",
|
||||
"kind": "update",
|
||||
"agent": "qa-tester:a8c34e4",
|
||||
"detail": "started qa-tester:a8c34e4",
|
||||
"sourceKey": "session-start:a8c34e4ce449d1c4b"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a8c34e4ce449d1c4b:2026-03-30T09:22:05.771Z",
|
||||
"at": "2026-03-30T09:22:05.771Z",
|
||||
"kind": "completion",
|
||||
"agent": "qa-tester:a8c34e4",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a8c34e4ce449d1c4b"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "ad233db7fa6f059dd",
|
||||
"agent_type": "Explore",
|
||||
"started_at": "2026-03-25T00:33:45.197Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T00:34:44.932Z",
|
||||
"duration_ms": 59735
|
||||
},
|
||||
{
|
||||
"agent_id": "a31a0f729d328643f",
|
||||
"agent_type": "Explore",
|
||||
"started_at": "2026-03-25T00:33:50.981Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T00:35:24.588Z",
|
||||
"duration_ms": 93607
|
||||
},
|
||||
{
|
||||
"agent_id": "a9510b7d8ec5a1ce7",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T00:37:40.106Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T00:42:01.730Z",
|
||||
"duration_ms": 261624
|
||||
},
|
||||
{
|
||||
"agent_id": "a1c1d186f0eb6dfc1",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T00:37:56.359Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T00:40:12.608Z",
|
||||
"duration_ms": 136249
|
||||
},
|
||||
{
|
||||
"agent_id": "a9a231d40fd5a150b",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T01:35:00.232Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T01:37:19.659Z",
|
||||
"duration_ms": 139427
|
||||
},
|
||||
{
|
||||
"agent_id": "a32b34c341b854da5",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:24.101Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:06:18.081Z",
|
||||
"duration_ms": 413980
|
||||
},
|
||||
{
|
||||
"agent_id": "ad2c89cf14936ea42",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:28.976Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:02:45.524Z",
|
||||
"duration_ms": 196548
|
||||
},
|
||||
{
|
||||
"agent_id": "a2c140c5a5adb0719",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:33.860Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:05:13.388Z",
|
||||
"duration_ms": 339528
|
||||
},
|
||||
{
|
||||
"agent_id": "a2e52136ea8f04385",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:39.166Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:03:53.163Z",
|
||||
"duration_ms": 253997
|
||||
},
|
||||
{
|
||||
"agent_id": "a3735bf51a74d6fc8",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:43.650Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:01:33.817Z",
|
||||
"duration_ms": 110167
|
||||
},
|
||||
{
|
||||
"agent_id": "a77742ba65fd2451c",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:48.683Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:06:09.324Z",
|
||||
"duration_ms": 380641
|
||||
},
|
||||
{
|
||||
"agent_id": "a4eb932c438b898c0",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:53.841Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:06:35.487Z",
|
||||
"duration_ms": 401646
|
||||
},
|
||||
{
|
||||
"agent_id": "a8c34e4ce449d1c4b",
|
||||
"agent_type": "oh-my-claudecode:qa-tester",
|
||||
"started_at": "2026-03-30T09:18:44.199Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-30T09:22:05.771Z",
|
||||
"duration_ms": 201572
|
||||
}
|
||||
],
|
||||
"total_spawned": 13,
|
||||
"total_completed": 13,
|
||||
"total_failed": 0,
|
||||
"last_updated": "2026-03-30T09:22:05.879Z"
|
||||
}
|
||||
@@ -522,6 +522,11 @@ body [role="button"] * {
|
||||
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
|
||||
/* 예: Calendar, Table 등의 미세 조정 */
|
||||
|
||||
/* 메신저 메시지 시간 표시 */
|
||||
body span.messenger-time {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* 테이블 레이아웃 고정 (셀 내용이 영역을 벗어나지 않도록) */
|
||||
.table-mobile-fixed {
|
||||
table-layout: fixed;
|
||||
|
||||
@@ -1,76 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { MessageSquare, Pencil, Check, X, ChevronsDown } from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { MessageSquare, Pencil, Check, X, ChevronsDown, Bell, BellOff } from "lucide-react";
|
||||
import { useMessages, useMarkAsRead, useUpdateRoom } from "@/hooks/useMessenger";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { useMessengerSocket } from "@/hooks/useMessengerSocket";
|
||||
import { MessageItem } from "./MessageItem";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
import type { Room } from "@/hooks/useMessenger";
|
||||
|
||||
interface ChatPanelProps {
|
||||
room: Room | null;
|
||||
typingUsers: Map<string, string[]>;
|
||||
emitTypingStart: (roomId: string) => void;
|
||||
emitTypingStop: (roomId: string) => void;
|
||||
onNewRoom: () => void;
|
||||
}
|
||||
|
||||
export function ChatPanel({ room }: ChatPanelProps) {
|
||||
const formatDateLabel = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
if (d.toDateString() === today.toDateString()) return "오늘";
|
||||
if (d.toDateString() === yesterday.toDateString()) return "어제";
|
||||
return d.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", weekday: "short" });
|
||||
};
|
||||
|
||||
export function ChatPanel({ room, typingUsers, emitTypingStart, emitTypingStop, onNewRoom }: ChatPanelProps) {
|
||||
const { user } = useAuth();
|
||||
const { selectedRoomId, isOpen } = useMessengerContext();
|
||||
const { selectedRoomId, isOpen, mutedRooms, toggleRoomMute } = useMessengerContext();
|
||||
const { data: messages } = useMessages(selectedRoomId);
|
||||
const markAsRead = useMarkAsRead();
|
||||
const updateRoom = useUpdateRoom();
|
||||
const { emitTypingStart, emitTypingStop, typingUsers } = useMessengerSocket();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRoomId) {
|
||||
if (isOpen && selectedRoomId) {
|
||||
markAsRead.mutate(selectedRoomId);
|
||||
}
|
||||
}, [selectedRoomId, messages?.length]);
|
||||
}, [isOpen, selectedRoomId, messages?.length]);
|
||||
|
||||
const lastMessageId = messages?.[messages.length - 1]?.id;
|
||||
|
||||
// Scroll to bottom: sentinel scrollIntoView before paint (no visible jump)
|
||||
// Scroll to bottom on room open / new message
|
||||
useLayoutEffect(() => {
|
||||
if (isOpen) bottomRef.current?.scrollIntoView();
|
||||
}, [selectedRoomId, lastMessageId, isOpen]);
|
||||
|
||||
// ResizeObserver: re-scroll whenever content height changes (images loading, etc.)
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const inner = el.firstElementChild as HTMLElement | null;
|
||||
if (!inner) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (shouldAutoScrollRef.current) {
|
||||
bottomRef.current?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
ro.observe(inner);
|
||||
return () => ro.disconnect();
|
||||
}, [selectedRoomId]);
|
||||
|
||||
// Track whether user has scrolled up (disable auto-scroll while reading old messages)
|
||||
useEffect(() => {
|
||||
shouldAutoScrollRef.current = true;
|
||||
}, [selectedRoomId, lastMessageId]);
|
||||
|
||||
// Re-attach scroll listener whenever room changes (scrollRef mounts after room is set)
|
||||
// Re-attach scroll listener whenever room changes
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
|
||||
setIsAtBottom(atBottom);
|
||||
shouldAutoScrollRef.current = atBottom;
|
||||
// In flex-col-reverse, scrollTop=0 means bottom (newest messages)
|
||||
setIsAtBottom(Math.abs(el.scrollTop) < 60);
|
||||
};
|
||||
el.addEventListener("scroll", onScroll);
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
@@ -78,20 +59,27 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-3">
|
||||
<MessageSquare className="h-10 w-10" />
|
||||
<p className="text-sm">대화를 선택하세요</p>
|
||||
<button
|
||||
onClick={onNewRoom}
|
||||
className="px-4 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
새 대화 시작하기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roomTyping = selectedRoomId ? typingUsers.get(selectedRoomId) : undefined;
|
||||
|
||||
// First message in a time group (shows avatar + name)
|
||||
// messages is oldest-first; flex-col-reverse CSS pins scroll to bottom (newest visible)
|
||||
const isFirstInGroup = (idx: number) => {
|
||||
if (!messages) return true;
|
||||
if (idx === 0) return true;
|
||||
const prev = messages![idx - 1];
|
||||
const curr = messages![idx];
|
||||
const prev = messages[idx - 1];
|
||||
const curr = messages[idx];
|
||||
if (prev.senderId !== curr.senderId || curr.isDeleted || prev.isDeleted) return true;
|
||||
const gap = new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime();
|
||||
return gap > 5 * 60 * 1000;
|
||||
@@ -103,16 +91,15 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
const curr = messages[idx];
|
||||
const next = messages[idx + 1];
|
||||
if (curr.senderId !== next.senderId || next.isDeleted || curr.isDeleted) return true;
|
||||
// Gap > 5 minutes → new time group
|
||||
const gap = new Date(next.createdAt).getTime() - new Date(curr.createdAt).getTime();
|
||||
return gap > 5 * 60 * 1000;
|
||||
};
|
||||
|
||||
// Date separator helper
|
||||
const shouldShowDate = (idx: number) => {
|
||||
if (!messages) return false;
|
||||
if (idx === 0) return true;
|
||||
const prev = new Date(messages![idx - 1].createdAt).toDateString();
|
||||
const curr = new Date(messages![idx].createdAt).toDateString();
|
||||
const prev = new Date(messages[idx - 1].createdAt).toDateString();
|
||||
const curr = new Date(messages[idx].createdAt).toDateString();
|
||||
return prev !== curr;
|
||||
};
|
||||
|
||||
@@ -163,34 +150,54 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
) : (
|
||||
<>
|
||||
<h3 className="font-semibold text-sm truncate">{displayName}</h3>
|
||||
<button
|
||||
onClick={() => { setEditName(room.name); setIsEditingName(true); }}
|
||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
{room.type !== "dm" && (
|
||||
<button
|
||||
onClick={() => { setEditName(room.name); setIsEditingName(true); }}
|
||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{room.participants.length}명
|
||||
</span>
|
||||
<button
|
||||
onClick={() => selectedRoomId && toggleRoomMute(selectedRoomId)}
|
||||
className="p-0.5 hover:bg-muted rounded shrink-0 ml-auto"
|
||||
title={selectedRoomId && mutedRooms.has(selectedRoomId) ? "알림 켜기" : "알림 끄기"}
|
||||
>
|
||||
{selectedRoomId && mutedRooms.has(selectedRoomId)
|
||||
? <BellOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
: <Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto relative">
|
||||
<div className="pt-2">
|
||||
{/* Messages — flex-col-reverse keeps scroll pinned to bottom (newest visible) */}
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto relative flex flex-col-reverse">
|
||||
<div className="pb-2">
|
||||
{/* Typing indicator at top of inner div = visually just above messages */}
|
||||
<div className="px-4 h-5 flex items-center text-xs text-muted-foreground gap-1">
|
||||
{roomTyping && roomTyping.length > 0 && (
|
||||
<>
|
||||
<span>{roomTyping.join(", ")}님이 입력 중</span>
|
||||
<span className="flex gap-0.5 items-center">
|
||||
<span className="w-1 h-1 bg-muted-foreground rounded-full animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-1 h-1 bg-muted-foreground rounded-full animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-1 h-1 bg-muted-foreground rounded-full animate-bounce [animation-delay:300ms]" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{messages?.map((msg, idx) => (
|
||||
<div key={msg.id}>
|
||||
{shouldShowDate(idx) && (
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(msg.createdAt).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
})}
|
||||
{formatDateLabel(msg.createdAt)}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
@@ -203,16 +210,12 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="px-4 h-5 flex items-center text-xs text-muted-foreground">
|
||||
{roomTyping && roomTyping.length > 0 ? `${roomTyping.join(", ")}님이 입력 중...` : ""}
|
||||
</div>
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }}
|
||||
onClick={() => { const el = scrollRef.current; if (el) el.scrollTop = 0; }}
|
||||
className="absolute bottom-14 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 bg-primary text-primary-foreground text-xs px-3 py-1.5 rounded-full shadow-md hover:bg-primary/90"
|
||||
>
|
||||
<ChevronsDown className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -125,6 +125,7 @@ function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps
|
||||
setText("");
|
||||
}
|
||||
|
||||
if (typingTimerRef.current) { clearTimeout(typingTimerRef.current); typingTimerRef.current = null; }
|
||||
onTypingStop?.();
|
||||
}, [text, pendingFiles, roomId, uploadFile, sendMessage, onTypingStop]);
|
||||
|
||||
@@ -155,7 +156,15 @@ function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps
|
||||
const val = e.target.value;
|
||||
setText(val);
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
if (val.trim()) { onTypingStart?.(); } else { onTypingStop?.(); }
|
||||
if (val.trim()) {
|
||||
onTypingStart?.();
|
||||
typingTimerRef.current = setTimeout(() => {
|
||||
onTypingStop?.();
|
||||
typingTimerRef.current = null;
|
||||
}, 3000);
|
||||
} else {
|
||||
onTypingStop?.();
|
||||
}
|
||||
const cursor = e.target.selectionStart;
|
||||
const atMatch = val.slice(0, cursor).match(/@(\S*)$/);
|
||||
if (atMatch) { setMentionQuery(atMatch[1]); setMentionIndex(0); } else { setMentionQuery(null); }
|
||||
@@ -232,7 +241,7 @@ function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps
|
||||
)}
|
||||
|
||||
{/* Input toolbar */}
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<div className="flex items-center gap-1 p-2 mx-2 mb-2 border rounded-lg focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30 transition-colors">
|
||||
<button onClick={() => fileRef.current?.click()} className="p-1.5 hover:bg-muted rounded shrink-0">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { UserAvatar } from "./UserAvatar";
|
||||
import { AuthImage } from "./AuthImage";
|
||||
import type { Message } from "@/hooks/useMessenger";
|
||||
import { useAddReaction } from "@/hooks/useMessenger";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const QUICK_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F44F}", "\u{1F64F}", "\u{1F525}"];
|
||||
|
||||
@@ -119,7 +120,7 @@ export function MessageItem({ message, isOwn, showAvatar, isLastInGroup }: Messa
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// download failed silently
|
||||
toast.error("파일 다운로드에 실패했습니다.");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
@@ -151,7 +152,11 @@ export function MessageItem({ message, isOwn, showAvatar, isLastInGroup }: Messa
|
||||
>
|
||||
<SmilePlus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-0.5 hover:bg-muted rounded">
|
||||
<button
|
||||
className="p-0.5 hover:bg-muted rounded opacity-50 cursor-not-allowed"
|
||||
title="준비 중"
|
||||
onClick={() => toast("답장 기능은 준비 중입니다.", { icon: "🚧" })}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { X, Settings } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { useRooms, useUnreadCount } from "@/hooks/useMessenger";
|
||||
import { useMessengerSocket } from "@/hooks/useMessengerSocket";
|
||||
import { RoomList } from "./RoomList";
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
import { MessengerSettings } from "./MessengerSettings";
|
||||
import { NewRoomModal } from "./NewRoomModal";
|
||||
|
||||
const MIN_W = 400, MIN_H = 320;
|
||||
const MAX_W = 1000, MAX_H = 800;
|
||||
@@ -25,7 +26,7 @@ export function MessengerModal() {
|
||||
const { isOpen, closeMessenger, selectedRoomId, setUnreadCount } = useMessengerContext();
|
||||
const { data: rooms = [] } = useRooms();
|
||||
const { data: serverUnread } = useUnreadCount();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const { userStatuses, typingUsers, emitTypingStart, emitTypingStop } = useMessengerSocket();
|
||||
|
||||
useEffect(() => {
|
||||
const count = (serverUnread as any)?.unread_count ?? serverUnread ?? 0;
|
||||
@@ -34,6 +35,7 @@ export function MessengerModal() {
|
||||
|
||||
|
||||
const selectedRoom = rooms.find((r) => r.id === selectedRoomId) || null;
|
||||
const [newRoomOpen, setNewRoomOpen] = useState(false);
|
||||
|
||||
// Position & size state
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
@@ -181,40 +183,21 @@ export function MessengerModal() {
|
||||
onMouseDown={onHeaderMouseDown}
|
||||
>
|
||||
<h2 className="text-sm font-semibold">메신저</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowSettings((p) => !p)}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
aria-label="설정"
|
||||
>
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={closeMessenger}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
aria-label="닫기"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeMessenger}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
aria-label="닫기"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 min-h-0 relative">
|
||||
<RoomList />
|
||||
<ChatPanel room={selectedRoom} />
|
||||
<RoomList userStatuses={userStatuses} newRoomOpen={newRoomOpen} setNewRoomOpen={setNewRoomOpen} />
|
||||
<ChatPanel room={selectedRoom} typingUsers={typingUsers} emitTypingStart={emitTypingStart} emitTypingStop={emitTypingStop} onNewRoom={() => setNewRoomOpen(true)} />
|
||||
<NewRoomModal open={newRoomOpen} onOpenChange={setNewRoomOpen} userStatuses={userStatuses} />
|
||||
|
||||
{showSettings && (
|
||||
<div className="absolute inset-0 bg-background z-10 flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<span className="text-sm font-semibold">설정</span>
|
||||
<button onClick={() => setShowSettings(false)} className="p-1 hover:bg-muted rounded">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<MessengerSettings />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -16,13 +16,15 @@ import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserStatus } from "@/hooks/useMessengerSocket";
|
||||
|
||||
interface NewRoomModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userStatuses: Map<string, UserStatus>;
|
||||
}
|
||||
|
||||
export function NewRoomModal({ open, onOpenChange }: NewRoomModalProps) {
|
||||
export function NewRoomModal({ open, onOpenChange, userStatuses }: NewRoomModalProps) {
|
||||
const [tab, setTab] = useState<"dm" | "group" | "channel">("dm");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
@@ -144,7 +146,7 @@ export function NewRoomModal({ open, onOpenChange }: NewRoomModalProps) {
|
||||
selected && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<UserAvatar photo={u.photo} name={u.userName} size="sm" />
|
||||
<UserAvatar photo={u.photo} name={u.userName} size="sm" status={userStatuses.get(u.userId) ?? "offline"} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{u.userName}</div>
|
||||
{u.deptName && (
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, BellOff } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useRooms } from "@/hooks/useMessenger";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
import { NewRoomModal } from "./NewRoomModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Room } from "@/hooks/useMessenger";
|
||||
import type { UserStatus } from "@/hooks/useMessengerSocket";
|
||||
|
||||
function formatTime(dateStr?: string) {
|
||||
if (!dateStr) return "";
|
||||
@@ -26,7 +25,7 @@ function formatTime(dateStr?: string) {
|
||||
return d.toLocaleDateString("ko-KR", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function RoomItem({ room, selected, onClick, currentUserId }: { room: Room; selected: boolean; onClick: () => void; currentUserId?: string }) {
|
||||
function RoomItem({ room, selected, onClick, currentUserId, userStatuses, isMuted }: { room: Room; selected: boolean; onClick: () => void; currentUserId?: string; userStatuses: Map<string, UserStatus>; isMuted: boolean }) {
|
||||
const otherParticipant = room.type === "dm"
|
||||
? room.participants.find((p) => p.userId !== currentUserId)
|
||||
: undefined;
|
||||
@@ -36,8 +35,8 @@ function RoomItem({ room, selected, onClick, currentUserId }: { room: Room; sele
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full grid items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left",
|
||||
selected && "bg-accent"
|
||||
"w-full grid items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left border-l-2 border-transparent",
|
||||
selected && "bg-accent border-l-primary"
|
||||
)}
|
||||
style={{ gridTemplateColumns: "36px 1fr" }}
|
||||
>
|
||||
@@ -45,10 +44,11 @@ function RoomItem({ room, selected, onClick, currentUserId }: { room: Room; sele
|
||||
photo={avatarParticipant?.photo}
|
||||
name={displayName || avatarParticipant?.userName || "?"}
|
||||
size="md"
|
||||
status={otherParticipant ? (userStatuses.get(otherParticipant.userId) ?? "offline") : undefined}
|
||||
/>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate min-w-0 flex-1">{displayName}</span>
|
||||
<span className={cn("text-sm truncate min-w-0 flex-1", selected ? "font-semibold" : "font-medium")}>{displayName}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 whitespace-nowrap">
|
||||
{formatTime(room.lastMessageAt)}
|
||||
</span>
|
||||
@@ -57,6 +57,7 @@ function RoomItem({ room, selected, onClick, currentUserId }: { room: Room; sele
|
||||
<span className="text-xs text-muted-foreground truncate min-w-0 flex-1">
|
||||
{room.lastMessage || "\u00A0"}
|
||||
</span>
|
||||
{isMuted && <BellOff className="h-3 w-3 text-muted-foreground shrink-0" />}
|
||||
{room.unreadCount > 0 && !selected && (
|
||||
<span className="ml-1 shrink-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground px-1">
|
||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||
@@ -68,11 +69,10 @@ function RoomItem({ room, selected, onClick, currentUserId }: { room: Room; sele
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomList() {
|
||||
export function RoomList({ userStatuses, newRoomOpen, setNewRoomOpen }: { userStatuses: Map<string, UserStatus>; newRoomOpen: boolean; setNewRoomOpen: (open: boolean) => void }) {
|
||||
const { data: rooms = [] } = useRooms();
|
||||
const { selectedRoomId, selectRoom } = useMessengerContext();
|
||||
const { selectedRoomId, selectRoom, mutedRooms } = useMessengerContext();
|
||||
const { user } = useAuth();
|
||||
const [newRoomOpen, setNewRoomOpen] = useState(false);
|
||||
|
||||
const dmRooms = rooms.filter((r) => r.type === "dm");
|
||||
const groupRooms = rooms.filter((r) => r.type === "group");
|
||||
@@ -89,6 +89,8 @@ export function RoomList() {
|
||||
selected={r.id === selectedRoomId}
|
||||
onClick={() => selectRoom(r.id)}
|
||||
currentUserId={user?.userId}
|
||||
userStatuses={userStatuses}
|
||||
isMuted={mutedRooms.has(String(r.id))}
|
||||
/>
|
||||
))
|
||||
);
|
||||
@@ -126,7 +128,6 @@ export function RoomList() {
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
<NewRoomModal open={newRoomOpen} onOpenChange={setNewRoomOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type UserStatus = "online" | "away" | "offline";
|
||||
|
||||
interface UserAvatarProps {
|
||||
photo?: string | null;
|
||||
name: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
status?: UserStatus;
|
||||
/** @deprecated use status instead */
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
@@ -17,12 +21,22 @@ const sizeMap = {
|
||||
};
|
||||
|
||||
const dotSizeMap = {
|
||||
sm: "h-2 w-2",
|
||||
md: "h-2.5 w-2.5",
|
||||
lg: "h-3 w-3",
|
||||
sm: "h-2.5 w-2.5",
|
||||
md: "h-3 w-3",
|
||||
lg: "h-3.5 w-3.5",
|
||||
};
|
||||
|
||||
export function UserAvatar({ photo, name, size = "md", online }: UserAvatarProps) {
|
||||
const statusColorMap: Record<UserStatus, string> = {
|
||||
online: "bg-green-500",
|
||||
away: "bg-yellow-400",
|
||||
offline: "bg-gray-500",
|
||||
};
|
||||
|
||||
export function UserAvatar({ photo, name, size = "md", status, online }: UserAvatarProps) {
|
||||
// Resolve effective status (support legacy `online` prop)
|
||||
const effectiveStatus: UserStatus | undefined =
|
||||
status ?? (online === true ? "online" : online === false ? "offline" : undefined);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block shrink-0">
|
||||
<Avatar className={cn(sizeMap[size])}>
|
||||
@@ -31,12 +45,12 @@ export function UserAvatar({ photo, name, size = "md", online }: UserAvatarProps
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{online !== undefined && (
|
||||
{effectiveStatus !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 right-0 rounded-full border-2 border-background",
|
||||
dotSizeMap[size],
|
||||
online ? "bg-green-500" : "bg-gray-300"
|
||||
statusColorMap[effectiveStatus]
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,11 +7,13 @@ interface MessengerContextValue {
|
||||
selectedRoomId: string | null;
|
||||
unreadCount: number;
|
||||
notificationEnabled: boolean;
|
||||
mutedRooms: Set<string>;
|
||||
openMessenger: (roomId?: string) => void;
|
||||
closeMessenger: () => void;
|
||||
selectRoom: (roomId: string) => void;
|
||||
setUnreadCount: (count: number) => void;
|
||||
toggleNotification: () => void;
|
||||
toggleRoomMute: (roomId: string) => void;
|
||||
}
|
||||
|
||||
const MessengerContext = createContext<MessengerContextValue | undefined>(undefined);
|
||||
@@ -21,12 +23,15 @@ export function MessengerProvider({ children }: { children: React.ReactNode }) {
|
||||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||
const [mutedRooms, setMutedRooms] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("messenger_notification");
|
||||
if (stored !== null) {
|
||||
setNotificationEnabled(stored === "true");
|
||||
}
|
||||
if (stored !== null) setNotificationEnabled(stored === "true");
|
||||
try {
|
||||
const muted = JSON.parse(localStorage.getItem("messenger_muted_rooms") || "[]");
|
||||
setMutedRooms(new Set(muted));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const openMessenger = useCallback((roomId?: string) => {
|
||||
@@ -50,6 +55,16 @@ export function MessengerProvider({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleRoomMute = useCallback((roomId: string) => {
|
||||
setMutedRooms((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(roomId)) next.delete(roomId);
|
||||
else next.add(roomId);
|
||||
localStorage.setItem("messenger_muted_rooms", JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MessengerContext.Provider
|
||||
value={{
|
||||
@@ -57,11 +72,13 @@ export function MessengerProvider({ children }: { children: React.ReactNode }) {
|
||||
selectedRoomId,
|
||||
unreadCount,
|
||||
notificationEnabled,
|
||||
mutedRooms,
|
||||
openMessenger,
|
||||
closeMessenger,
|
||||
selectRoom,
|
||||
setUnreadCount,
|
||||
toggleNotification,
|
||||
toggleRoomMute,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { apiClient, API_BASE_URL } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
// ============================================
|
||||
@@ -34,6 +34,7 @@ export interface Message {
|
||||
type: "text" | "file" | "system";
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
fileMimeType?: string | null;
|
||||
reactions: Reaction[];
|
||||
threadCount?: number;
|
||||
parentId?: string | null;
|
||||
@@ -111,8 +112,11 @@ export function useMessages(roomId: string | null) {
|
||||
: (m.senderPhoto ?? null),
|
||||
content: m.content ?? "",
|
||||
type: m.message_type ?? m.type ?? "text",
|
||||
fileUrl: m.file_url ?? m.fileUrl,
|
||||
fileName: m.file_name ?? m.fileName,
|
||||
fileUrl: m.files?.[0]?.id
|
||||
? `${API_BASE_URL}/messenger/files/${m.files[0].id}`
|
||||
: (m.file_url ?? m.fileUrl),
|
||||
fileName: m.files?.[0]?.original_name ?? m.file_name ?? m.fileName,
|
||||
fileMimeType: m.files?.[0]?.mime_type ?? null,
|
||||
reactions: m.reactions ?? [],
|
||||
threadCount: m.thread_count ?? m.threadCount ?? 0,
|
||||
parentId: m.parent_message_id ?? m.parentId ?? null,
|
||||
@@ -151,9 +155,37 @@ export function useUnreadCount() {
|
||||
|
||||
export function useSendMessage() {
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { roomId: string; content: string; type?: string; parentId?: string | null }) =>
|
||||
postApi(`/messenger/rooms/${payload.roomId}/messages`, payload),
|
||||
mutationFn: (payload: { roomId: string; content: string; type?: string; parentId?: string | null; fileUrl?: string; fileName?: string }) =>
|
||||
postApi(`/messenger/rooms/${payload.roomId}/messages`, {
|
||||
content: payload.content,
|
||||
type: payload.type,
|
||||
parentId: payload.parentId,
|
||||
file_url: payload.fileUrl,
|
||||
file_name: payload.fileName,
|
||||
}),
|
||||
onMutate: async (variables) => {
|
||||
const queryKey = ["messenger", "messages", variables.roomId];
|
||||
await qc.cancelQueries({ queryKey });
|
||||
const previous = qc.getQueryData<Message[]>(queryKey);
|
||||
const optimistic: Message = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
roomId: variables.roomId,
|
||||
senderId: user?.userId ?? "me",
|
||||
senderName: "",
|
||||
content: variables.content,
|
||||
type: (variables.type as Message["type"]) ?? "text",
|
||||
reactions: [],
|
||||
isDeleted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
qc.setQueryData<Message[]>(queryKey, (old) => [...(old ?? []), optimistic]);
|
||||
return { previous, queryKey };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context) qc.setQueryData(context.queryKey, context.previous);
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", variables.roomId] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
@@ -206,14 +238,20 @@ export function useUpdateRoom() {
|
||||
}
|
||||
|
||||
export function useUploadFile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
mutationFn: async ({ file, roomId }: { file: File; roomId: string }) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("files", file);
|
||||
formData.append("room_id", roomId);
|
||||
const res = await apiClient.post("/messenger/files/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data?.data ?? res.data;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", variables.roomId] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8080";
|
||||
|
||||
interface NewMessageEvent {
|
||||
room_id: number;
|
||||
sender_name: string;
|
||||
sender_id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TypingEvent {
|
||||
room_id: number;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
export function useMessengerSocket() {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const { selectedRoomId, notificationEnabled } = useMessengerContext();
|
||||
const selectedRoomIdRef = useRef(selectedRoomId);
|
||||
const notificationEnabledRef = useRef(notificationEnabled);
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [onlineUsers, setOnlineUsers] = useState<Set<string>>(new Set());
|
||||
const [typingUsers, setTypingUsers] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
// Keep refs in sync so socket handlers use latest values
|
||||
useEffect(() => {
|
||||
selectedRoomIdRef.current = selectedRoomId;
|
||||
}, [selectedRoomId]);
|
||||
|
||||
useEffect(() => {
|
||||
notificationEnabledRef.current = notificationEnabled;
|
||||
}, [notificationEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(BACKEND_URL, {
|
||||
path: "/socket.io",
|
||||
auth: { token },
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
// BUG-8: Join rooms on connect
|
||||
socket.on("connect", () => {
|
||||
socket.emit("join_rooms");
|
||||
});
|
||||
|
||||
socket.on("user_online", (data: { userId: string; online: boolean }) => {
|
||||
setOnlineUsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (data.online) next.add(data.userId);
|
||||
else next.delete(data.userId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
// BUG-5 & BUG-8: Handle new_message with cache invalidation and toast
|
||||
socket.on("new_message", (data: NewMessageEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
|
||||
// Invalidate React Query caches
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", roomIdStr] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "unread"] });
|
||||
|
||||
// Toast for messages in other rooms
|
||||
if (roomIdStr !== selectedRoomIdRef.current && notificationEnabledRef.current) {
|
||||
toast({
|
||||
title: data.sender_name,
|
||||
description: data.content?.slice(0, 50),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// BUG-7: Backend emits "user_typing" / "user_stop_typing", not "typing_start" / "typing_stop"
|
||||
socket.on("user_typing", (data: TypingEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(roomIdStr) || [];
|
||||
if (!users.includes(data.user_name)) {
|
||||
next.set(roomIdStr, [...users, data.user_name]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("user_stop_typing", (data: TypingEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(roomIdStr) || [];
|
||||
next.set(
|
||||
roomIdStr,
|
||||
users.filter((u) => u !== data.user_name)
|
||||
);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [toast, qc]);
|
||||
|
||||
// BUG-7: Backend expects { room_id }, not { roomId }
|
||||
const emitTypingStart = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_start", { room_id: Number(roomId) });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const emitTypingStop = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_stop", { room_id: Number(roomId) });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { socket: socketRef, onlineUsers, typingUsers, emitTypingStart, emitTypingStop };
|
||||
}
|
||||
186
frontend/hooks/useMessengerSocket.tsx
Normal file
186
frontend/hooks/useMessengerSocket.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8080";
|
||||
|
||||
export type UserStatus = "online" | "away" | "offline";
|
||||
|
||||
interface NewMessageEvent {
|
||||
room_id: number;
|
||||
sender_name: string;
|
||||
sender_id: string;
|
||||
sender_photo?: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TypingEvent {
|
||||
room_id: number;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
export function useMessengerSocket() {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const { selectedRoomId, notificationEnabled, isOpen, openMessenger, mutedRooms } = useMessengerContext();
|
||||
const currentUserIdRef = useRef<string | null>(null);
|
||||
const selectedRoomIdRef = useRef(selectedRoomId);
|
||||
const notificationEnabledRef = useRef(notificationEnabled);
|
||||
const isOpenRef = useRef(isOpen);
|
||||
const mutedRoomsRef = useRef(mutedRooms);
|
||||
|
||||
const qc = useQueryClient();
|
||||
const [userStatuses, setUserStatuses] = useState<Map<string, UserStatus>>(new Map());
|
||||
const [typingUsers, setTypingUsers] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
// Keep refs in sync so socket handlers use latest values
|
||||
useEffect(() => {
|
||||
selectedRoomIdRef.current = selectedRoomId;
|
||||
}, [selectedRoomId]);
|
||||
|
||||
useEffect(() => {
|
||||
notificationEnabledRef.current = notificationEnabled;
|
||||
}, [notificationEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
isOpenRef.current = isOpen;
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
mutedRoomsRef.current = mutedRooms;
|
||||
}, [mutedRooms]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
currentUserIdRef.current = payload.userId ?? payload.user_id ?? null;
|
||||
} catch {}
|
||||
|
||||
const socket = io(BACKEND_URL, {
|
||||
path: "/socket.io",
|
||||
auth: { token },
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () => {
|
||||
socket.emit("join_rooms");
|
||||
});
|
||||
|
||||
// Receive full presence list on connect
|
||||
socket.on("presence_list", (data: Record<string, string>) => {
|
||||
setUserStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [uid, status] of Object.entries(data)) {
|
||||
next.set(uid, status as UserStatus);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
// Receive individual status updates
|
||||
socket.on("user_status", (data: { userId: string; status: UserStatus }) => {
|
||||
setUserStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (data.status === "offline") {
|
||||
next.delete(data.userId);
|
||||
} else {
|
||||
next.set(data.userId, data.status);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
// Tab visibility → away/online
|
||||
const handleVisibilityChange = () => {
|
||||
const status = document.hidden ? "away" : "online";
|
||||
socket.emit("set_status", { status });
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
// BUG-5 & BUG-8: Handle new_message with cache invalidation and toast
|
||||
socket.on("new_message", (data: NewMessageEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", roomIdStr] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "unread"] });
|
||||
|
||||
const isOwnMessage = data.sender_id === currentUserIdRef.current;
|
||||
const isRoomMuted = mutedRoomsRef.current.has(roomIdStr);
|
||||
if (notificationEnabledRef.current && !isOwnMessage && !isRoomMuted && (!isOpenRef.current || roomIdStr !== selectedRoomIdRef.current)) {
|
||||
const photoSrc = data.sender_photo
|
||||
? `data:image/jpeg;base64,${data.sender_photo}`
|
||||
: null;
|
||||
toast(
|
||||
<div className="flex items-center gap-2 cursor-pointer" onClick={() => openMessenger(roomIdStr)}>
|
||||
{photoSrc ? (
|
||||
<img src={photoSrc} alt="" className="w-6 h-6 rounded-full object-cover shrink-0" />
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium shrink-0">
|
||||
{(data.sender_name || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm">{data.sender_name || "새 메시지"}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{data.content?.slice(0, 60) || ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// BUG-7: Backend emits "user_typing" / "user_stop_typing"
|
||||
socket.on("user_typing", (data: TypingEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(roomIdStr) || [];
|
||||
if (!users.includes(data.user_name)) {
|
||||
next.set(roomIdStr, [...users, data.user_name]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("user_stop_typing", (data: TypingEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(roomIdStr) || [];
|
||||
next.set(roomIdStr, users.filter((u) => u !== data.user_name));
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [toast, qc]);
|
||||
|
||||
const emitTypingStart = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_start", { room_id: Number(roomId) });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const emitTypingStop = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_stop", { room_id: Number(roomId) });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { socket: socketRef, userStatuses, typingUsers, emitTypingStart, emitTypingStop };
|
||||
}
|
||||
Reference in New Issue
Block a user