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

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

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

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

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

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

1
.gitignore vendored
View File

@@ -229,3 +229,4 @@ frontend/test-results/
frontend/playwright.config.ts
frontend/tests/
frontend/test-results/
db/checkpoints/

View File

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

View File

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

View File

@@ -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}

View File

@@ -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}

View File

@@ -1,3 +0,0 @@
{
"lastSentAt": "2026-03-27T04:34:49.003Z"
}

View File

@@ -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
}

View File

@@ -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"
}
]
}
]
}

View File

@@ -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"
}

View File

@@ -522,6 +522,11 @@ body [role="button"] * {
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
/* 예: Calendar, Table 등의 미세 조정 */
/* 메신저 메시지 시간 표시 */
body span.messenger-time {
font-size: 13px !important;
}
/* 테이블 레이아웃 고정 (셀 내용이 영역을 벗어나지 않도록) */
.table-mobile-fixed {
table-layout: fixed;

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</>

View File

@@ -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 && (

View File

@@ -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>
);
}

View File

@@ -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]
)}
/>
)}

View File

@@ -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}

View File

@@ -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"] });
},
});
}

View File

@@ -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 };
}

View 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 };
}