diff --git a/.gitignore b/.gitignore index 378b5e55..97029453 100644 --- a/.gitignore +++ b/.gitignore @@ -229,3 +229,4 @@ frontend/test-results/ frontend/playwright.config.ts frontend/tests/ frontend/test-results/ +db/checkpoints/ diff --git a/backend-node/src/controllers/messengerController.ts b/backend-node/src/controllers/messengerController.ts index b1fe2e11..5db22901 100644 --- a/backend-node/src/controllers/messengerController.ts +++ b/backend-node/src/controllers/messengerController.ts @@ -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 }); } diff --git a/backend-node/src/socket/messengerSocket.ts b/backend-node/src/socket/messengerSocket.ts index 355bf4b5..048e841d 100644 --- a/backend-node/src/socket/messengerSocket.ts +++ b/backend-node/src/socket/messengerSocket.ts @@ -12,6 +12,9 @@ interface AuthenticatedSocket extends Socket { }; } +// In-memory presence store: userId → { companyCode, status } +const presenceStore = new Map(); + 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 = {}; + 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' }); }); }); } diff --git a/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl b/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl deleted file mode 100644 index eeffca86..00000000 --- a/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl +++ /dev/null @@ -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} diff --git a/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl b/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl deleted file mode 100644 index 64204160..00000000 --- a/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl +++ /dev/null @@ -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} diff --git a/frontend/.omc/state/idle-notif-cooldown.json b/frontend/.omc/state/idle-notif-cooldown.json deleted file mode 100644 index 9cccbde4..00000000 --- a/frontend/.omc/state/idle-notif-cooldown.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "lastSentAt": "2026-03-27T04:34:49.003Z" -} \ No newline at end of file diff --git a/frontend/.omc/state/last-tool-error.json b/frontend/.omc/state/last-tool-error.json deleted file mode 100644 index 4f08ef45..00000000 --- a/frontend/.omc/state/last-tool-error.json +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/frontend/.omc/state/mission-state.json b/frontend/.omc/state/mission-state.json deleted file mode 100644 index 9356ac70..00000000 --- a/frontend/.omc/state/mission-state.json +++ /dev/null @@ -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" - } - ] - } - ] -} \ No newline at end of file diff --git a/frontend/.omc/state/subagent-tracking.json b/frontend/.omc/state/subagent-tracking.json deleted file mode 100644 index de699dd5..00000000 --- a/frontend/.omc/state/subagent-tracking.json +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b793f991..f2978a19 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -522,6 +522,11 @@ body [role="button"] * { /* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */ /* 예: Calendar, Table 등의 미세 조정 */ +/* 메신저 메시지 시간 표시 */ +body span.messenger-time { + font-size: 13px !important; +} + /* 테이블 레이아웃 고정 (셀 내용이 영역을 벗어나지 않도록) */ .table-mobile-fixed { table-layout: fixed; diff --git a/frontend/components/messenger/ChatPanel.tsx b/frontend/components/messenger/ChatPanel.tsx index b223ba3a..904fbbee 100644 --- a/frontend/components/messenger/ChatPanel.tsx +++ b/frontend/components/messenger/ChatPanel.tsx @@ -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; + 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(null); const [isAtBottom, setIsAtBottom] = useState(true); - const bottomRef = useRef(null); const [isEditingName, setIsEditingName] = useState(false); const [editName, setEditName] = useState(""); const editInputRef = useRef(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 ( -
+

대화를 선택하세요

+
); } 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) { ) : ( <>

{displayName}

- + {room.type !== "dm" && ( + + )} {room.participants.length}명 + )}
- {/* Messages */} -
-
+ {/* Messages — flex-col-reverse keeps scroll pinned to bottom (newest visible) */} +
+
+ {/* Typing indicator at top of inner div = visually just above messages */} +
+ {roomTyping && roomTyping.length > 0 && ( + <> + {roomTyping.join(", ")}님이 입력 중 + + + + + + + )} +
{messages?.map((msg, idx) => (
{shouldShowDate(idx) && (
- {new Date(msg.createdAt).toLocaleDateString("ko-KR", { - year: "numeric", - month: "long", - day: "numeric", - weekday: "short", - })} + {formatDateLabel(msg.createdAt)}
@@ -203,16 +210,12 @@ export function ChatPanel({ room }: ChatPanelProps) { />
))} -
- {roomTyping && roomTyping.length > 0 ? `${roomTyping.join(", ")}님이 입력 중...` : ""} -
-
{!isAtBottom && ( diff --git a/frontend/components/messenger/MessageItem.tsx b/frontend/components/messenger/MessageItem.tsx index 1adc59c2..7ba7e44e 100644 --- a/frontend/components/messenger/MessageItem.tsx +++ b/frontend/components/messenger/MessageItem.tsx @@ -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 > -
diff --git a/frontend/components/messenger/MessengerModal.tsx b/frontend/components/messenger/MessengerModal.tsx index 0e3f270b..70b9b9c0 100644 --- a/frontend/components/messenger/MessengerModal.tsx +++ b/frontend/components/messenger/MessengerModal.tsx @@ -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} >

메신저

-
- - -
+
{/* Body */}
- - + + setNewRoomOpen(true)} /> + - {showSettings && ( -
-
- 설정 - -
- -
- )}
diff --git a/frontend/components/messenger/NewRoomModal.tsx b/frontend/components/messenger/NewRoomModal.tsx index e564358b..09fa513c 100644 --- a/frontend/components/messenger/NewRoomModal.tsx +++ b/frontend/components/messenger/NewRoomModal.tsx @@ -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; } -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([]); @@ -144,7 +146,7 @@ export function NewRoomModal({ open, onOpenChange }: NewRoomModalProps) { selected && "bg-accent" )} > - +
{u.userName}
{u.deptName && ( diff --git a/frontend/components/messenger/RoomList.tsx b/frontend/components/messenger/RoomList.tsx index 842c453d..a32eee3d 100644 --- a/frontend/components/messenger/RoomList.tsx +++ b/frontend/components/messenger/RoomList.tsx @@ -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; 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