diff --git a/frontend/components/messenger/ChatPanel.tsx b/frontend/components/messenger/ChatPanel.tsx index 28101f38..3a66feb4 100644 --- a/frontend/components/messenger/ChatPanel.tsx +++ b/frontend/components/messenger/ChatPanel.tsx @@ -1,28 +1,29 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import { MessageSquare, Pencil, Check, X } from "lucide-react"; -import { ScrollArea } from "@/components/ui/scroll-area"; 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 { MessageInputHandle } from "./MessageInput"; import type { Room } from "@/hooks/useMessenger"; interface ChatPanelProps { room: Room | null; + messageInputRef?: React.RefObject; } -export function ChatPanel({ room }: ChatPanelProps) { +export function ChatPanel({ room, messageInputRef }: ChatPanelProps) { const { user } = useAuth(); const { selectedRoomId } = useMessengerContext(); const { data: messages } = useMessages(selectedRoomId); const markAsRead = useMarkAsRead(); const updateRoom = useUpdateRoom(); const { emitTypingStart, emitTypingStop, typingUsers } = useMessengerSocket(); - const bottomRef = useRef(null); + const scrollRef = useRef(null); const [isEditingName, setIsEditingName] = useState(false); const [editName, setEditName] = useState(""); const editInputRef = useRef(null); @@ -33,9 +34,12 @@ export function ChatPanel({ room }: ChatPanelProps) { } }, [selectedRoomId, messages?.length]); - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages?.length]); + const lastMessageId = messages?.[messages.length - 1]?.id; + + useLayoutEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [lastMessageId]); if (!room) { return ( @@ -48,12 +52,25 @@ export function ChatPanel({ room }: ChatPanelProps) { const roomTyping = selectedRoomId ? typingUsers.get(selectedRoomId) : undefined; - // Group consecutive messages from same sender - const isSameGroup = (idx: number) => { - if (idx === 0) return false; + // First message in a time group (shows avatar + name) + const isFirstInGroup = (idx: number) => { + if (idx === 0) return true; const prev = messages![idx - 1]; const curr = messages![idx]; - return prev.senderId === curr.senderId && !curr.isDeleted && !prev.isDeleted; + 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; + }; + + const isLastInGroup = (idx: number) => { + if (!messages) return true; + if (idx === messages.length - 1) return true; + 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 @@ -79,7 +96,7 @@ export function ChatPanel({ room }: ChatPanelProps) { })(); return ( -
+
{/* Header */}
{isEditingName ? ( @@ -125,8 +142,8 @@ export function ChatPanel({ room }: ChatPanelProps) {
{/* Messages */} - -
+
+
{messages?.map((msg, idx) => (
{shouldShowDate(idx) && ( @@ -146,23 +163,20 @@ export function ChatPanel({ room }: ChatPanelProps) {
))} -
+
+ {roomTyping && roomTyping.length > 0 ? `${roomTyping.join(", ")}님이 입력 중...` : ""} +
- - - {/* Typing indicator */} - {roomTyping && roomTyping.length > 0 && ( -
- {roomTyping.join(", ")}님이 입력 중... -
- )} +
{/* Input */} emitTypingStart(room.id)} onTypingStop={() => emitTypingStop(room.id)} diff --git a/frontend/components/messenger/MessageInput.tsx b/frontend/components/messenger/MessageInput.tsx index 10bed48f..da0b5d3c 100644 --- a/frontend/components/messenger/MessageInput.tsx +++ b/frontend/components/messenger/MessageInput.tsx @@ -1,20 +1,33 @@ "use client"; -import { useState, useRef, useCallback, useEffect, KeyboardEvent, ChangeEvent } from "react"; -import { Paperclip, Send, SmilePlus } from "lucide-react"; +import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle, KeyboardEvent, ChangeEvent } from "react"; +import { Paperclip, Send, SmilePlus, X, FileIcon } from "lucide-react"; import { useSendMessage, useUploadFile, useCompanyUsers } from "@/hooks/useMessenger"; +import { toast } from "sonner"; const QUICK_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F44F}", "\u{1F64F}", "\u{1F525}", "\u{1F389}", "\u{1F914}"]; +interface PendingFile { + file: File; + previewUrl: string | null; // object URL for images, null for others +} + +export interface MessageInputHandle { + addFiles: (files: File[]) => void; +} + interface MessageInputProps { roomId: string; onTypingStart?: () => void; onTypingStop?: () => void; } -export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps) { +export const MessageInput = forwardRef( +function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps, ref) { const [text, setText] = useState(""); + const [pendingFiles, setPendingFiles] = useState([]); const [showEmoji, setShowEmoji] = useState(false); + const emojiRef = useRef(null); const [mentionQuery, setMentionQuery] = useState(null); const [mentionIndex, setMentionIndex] = useState(0); const textareaRef = useRef(null); @@ -29,6 +42,13 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp ? users.filter((u) => u.userName.toLowerCase().includes(mentionQuery.toLowerCase())).slice(0, 5) : []; + // Revoke object URLs when pending files change or component unmounts + useEffect(() => { + return () => { + pendingFiles.forEach((pf) => { if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl); }); + }; + }, [pendingFiles]); + const adjustHeight = useCallback(() => { const el = textareaRef.current; if (el) { @@ -37,49 +57,85 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp } }, []); - useEffect(() => { - adjustHeight(); - }, [text, adjustHeight]); + useEffect(() => { adjustHeight(); }, [text, adjustHeight]); - // Auto-focus when room changes useEffect(() => { - if (roomId) { - textareaRef.current?.focus(); - } + if (roomId) textareaRef.current?.focus(); }, [roomId]); - const handleSend = useCallback(() => { + // Close emoji picker on outside click + useEffect(() => { + if (!showEmoji) return; + const handler = (e: MouseEvent) => { + if (emojiRef.current && !emojiRef.current.contains(e.target as Node)) setShowEmoji(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showEmoji]); + + const BLOCKED_TYPES = /^(audio|video)\//; + + const addFiles = (files: FileList | File[]) => { + const arr = Array.from(files).filter((f) => { + if (BLOCKED_TYPES.test(f.type)) { + toast(`${f.name} — 음원/동영상 파일은 전송할 수 없습니다.`); + return false; + } + return true; + }); + if (arr.length === 0) return; + setPendingFiles((prev) => [ + ...prev, + ...arr.map((file) => ({ + file, + previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : null, + })), + ]); + textareaRef.current?.focus(); + }; + + useImperativeHandle(ref, () => ({ addFiles })); + + const removePendingFile = (idx: number) => { + setPendingFiles((prev) => { + const pf = prev[idx]; + if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl); + return prev.filter((_, i) => i !== idx); + }); + }; + + const handleSend = useCallback(async () => { const trimmed = text.trim(); - if (!trimmed) return; - sendMessage.mutate({ roomId, content: trimmed }); - setText(""); + if (!trimmed && pendingFiles.length === 0) return; + + // Upload all pending files (backend creates a message per file) + for (const pf of pendingFiles) { + try { + await uploadFile.mutateAsync({ file: pf.file, roomId }); + if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl); + } catch { + // upload failed silently + } + } + setPendingFiles([]); + + // Send text message if any + if (trimmed) { + sendMessage.mutate({ roomId, content: trimmed }); + setText(""); + } + onTypingStop?.(); - }, [text, roomId, sendMessage, onTypingStop]); + }, [text, pendingFiles, roomId, uploadFile, sendMessage, onTypingStop]); const handleKeyDown = (e: KeyboardEvent) => { if (mentionQuery !== null && filteredMentionUsers.length > 0) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setMentionIndex((p) => Math.min(p + 1, filteredMentionUsers.length - 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setMentionIndex((p) => Math.max(p - 1, 0)); - return; - } - if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault(); - insertMention(filteredMentionUsers[mentionIndex]); - return; - } - if (e.key === "Escape") { - setMentionQuery(null); - return; - } + if (e.key === "ArrowDown") { e.preventDefault(); setMentionIndex((p) => Math.min(p + 1, filteredMentionUsers.length - 1)); return; } + if (e.key === "ArrowUp") { e.preventDefault(); setMentionIndex((p) => Math.max(p - 1, 0)); return; } + if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); insertMention(filteredMentionUsers[mentionIndex]); return; } + if (e.key === "Escape") { setMentionQuery(null); return; } } - - if (e.key === "Enter" && !e.shiftKey) { + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); handleSend(); } @@ -91,53 +147,49 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp const val = el.value; const atIdx = val.lastIndexOf("@", el.selectionStart - 1); if (atIdx === -1) return; - const before = val.slice(0, atIdx); - const after = val.slice(el.selectionStart); - setText(`${before}@${user.userName} ${after}`); + setText(`${val.slice(0, atIdx)}@${user.userName} ${val.slice(el.selectionStart)}`); setMentionQuery(null); }; const handleChange = (e: ChangeEvent) => { const val = e.target.value; setText(val); - - // Typing events - onTypingStart?.(); if (typingTimerRef.current) clearTimeout(typingTimerRef.current); - typingTimerRef.current = setTimeout(() => onTypingStop?.(), 2000); - - // Mention detection + if (val.trim()) { onTypingStart?.(); } else { onTypingStop?.(); } const cursor = e.target.selectionStart; - const textBeforeCursor = val.slice(0, cursor); - const atMatch = textBeforeCursor.match(/@(\S*)$/); - if (atMatch) { - setMentionQuery(atMatch[1]); - setMentionIndex(0); - } else { - setMentionQuery(null); - } + const atMatch = val.slice(0, cursor).match(/@(\S*)$/); + if (atMatch) { setMentionQuery(atMatch[1]); setMentionIndex(0); } else { setMentionQuery(null); } }; - const handleFileChange = async (e: ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - try { - const result = await uploadFile.mutateAsync(file); - sendMessage.mutate({ - roomId, - content: file.name, - type: "file", - }); - } catch { - // upload failed silently - } + const handleFileChange = (e: ChangeEvent) => { + if (e.target.files?.length) addFiles(e.target.files); e.target.value = ""; }; + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + if (e.clipboardData.files?.length) { + e.preventDefault(); + addFiles(e.clipboardData.files); + } + }; + + const canSend = text.trim().length > 0 || pendingFiles.length > 0; + return ( -
+
e.preventDefault()} + onDrop={handleDrop} + > + + {/* Mention dropdown */} {mentionQuery !== null && filteredMentionUsers.length > 0 && ( -
+
{filteredMentionUsers.map((u, i) => ( +
+ ))} +
+ )} + + {/* Input toolbar */} +
+ - + -
+
@@ -166,11 +247,7 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp {QUICK_EMOJIS.map((emoji) => (
); -} +}); diff --git a/frontend/components/messenger/MessengerModal.tsx b/frontend/components/messenger/MessengerModal.tsx index 6e449e99..4e0ce1b8 100644 --- a/frontend/components/messenger/MessengerModal.tsx +++ b/frontend/components/messenger/MessengerModal.tsx @@ -1,31 +1,206 @@ "use client"; -import { useState } from "react"; -import { X, Settings } from "lucide-react"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { X, Settings, Scissors } from "lucide-react"; import { useMessengerContext } from "@/contexts/MessengerContext"; -import { useRooms } from "@/hooks/useMessenger"; +import { useRooms, useUnreadCount } from "@/hooks/useMessenger"; import { RoomList } from "./RoomList"; import { ChatPanel } from "./ChatPanel"; import { MessengerSettings } from "./MessengerSettings"; +import { ScreenCapture } from "./ScreenCapture"; +import type { MessageInputHandle } from "./MessageInput"; + +const MIN_W = 400, MIN_H = 320; +const MAX_W = 1000, MAX_H = 800; +const INIT_W = 720, INIT_H = 500; + +type ResizeDir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; + +const CURSOR: Record = { + n: "ns-resize", s: "ns-resize", + e: "ew-resize", w: "ew-resize", + ne: "nesw-resize", sw: "nesw-resize", + nw: "nwse-resize", se: "nwse-resize", +}; export function MessengerModal() { - const { isOpen, closeMessenger, selectedRoomId } = useMessengerContext(); + const { isOpen, closeMessenger, selectedRoomId, setUnreadCount } = useMessengerContext(); const { data: rooms = [] } = useRooms(); + const { data: serverUnread } = useUnreadCount(); const [showSettings, setShowSettings] = useState(false); + const [capturing, setCapturing] = useState(false); + const messageInputRef = useRef(null); - if (!isOpen) return null; + useEffect(() => { + const count = (serverUnread as any)?.unread_count ?? serverUnread ?? 0; + setUnreadCount(Number(count)); + }, [serverUnread, setUnreadCount]); const selectedRoom = rooms.find((r) => r.id === selectedRoomId) || null; - return ( + // Position & size state + const [pos, setPos] = useState({ x: 0, y: 0 }); + const [size, setSize] = useState({ w: INIT_W, h: INIT_H }); + const initialized = useRef(false); + + // Initialize position to bottom-right on first open + useEffect(() => { + if (isOpen && !initialized.current) { + setPos({ + x: window.innerWidth - INIT_W - 24, + y: window.innerHeight - INIT_H - 24, + }); + initialized.current = true; + } + }, [isOpen]); + + // Clamp position when window resizes + useEffect(() => { + const onResize = () => { + setPos((p) => ({ + x: Math.min(p.x, window.innerWidth - size.w), + y: Math.min(p.y, window.innerHeight - size.h), + })); + }; + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [size]); + + // --- Drag to move --- + const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + + const onHeaderMouseDown = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + e.preventDefault(); + dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y }; + + const onMove = (ev: MouseEvent) => { + if (!dragRef.current) return; + const dx = ev.clientX - dragRef.current.startX; + const dy = ev.clientY - dragRef.current.startY; + setPos({ + x: Math.max(0, Math.min(dragRef.current.origX + dx, window.innerWidth - size.w)), + y: Math.max(0, Math.min(dragRef.current.origY + dy, window.innerHeight - size.h)), + }); + }; + const onUp = () => { + dragRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [pos, size]); + + // --- Resize --- + const resizeRef = useRef<{ + dir: ResizeDir; + startX: number; startY: number; + origX: number; origY: number; + origW: number; origH: number; + } | null>(null); + + const onResizeMouseDown = useCallback((e: React.MouseEvent, dir: ResizeDir) => { + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { + dir, + startX: e.clientX, startY: e.clientY, + origX: pos.x, origY: pos.y, + origW: size.w, origH: size.h, + }; + + const onMove = (ev: MouseEvent) => { + const r = resizeRef.current; + if (!r) return; + const dx = ev.clientX - r.startX; + const dy = ev.clientY - r.startY; + + let newX = r.origX, newY = r.origY, newW = r.origW, newH = r.origH; + + if (r.dir.includes("e")) newW = Math.min(MAX_W, Math.max(MIN_W, r.origW + dx)); + if (r.dir.includes("s")) newH = Math.min(MAX_H, Math.max(MIN_H, r.origH + dy)); + if (r.dir.includes("w")) { + const w = Math.min(MAX_W, Math.max(MIN_W, r.origW - dx)); + newX = r.origX + (r.origW - w); + newW = w; + } + if (r.dir.includes("n")) { + const h = Math.min(MAX_H, Math.max(MIN_H, r.origH - dy)); + newY = r.origY + (r.origH - h); + newH = h; + } + + // Clamp to viewport + newX = Math.max(0, Math.min(newX, window.innerWidth - newW)); + newY = Math.max(0, Math.min(newY, window.innerHeight - newH)); + + setPos({ x: newX, y: newY }); + setSize({ w: newW, h: newH }); + }; + + const onUp = () => { + resizeRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [pos, size]); + + const handle = (dir: ResizeDir, className: string) => (
onResizeMouseDown(e, dir)} + /> + ); + + return ( + <> + {capturing && ( + { + setCapturing(false); + messageInputRef.current?.addFiles([file]); + }} + onCancel={() => setCapturing(false)} + /> + )} +
+ {/* Resize handles */} + {handle("n", "top-0 left-2 right-2 h-1.5")} + {handle("s", "bottom-0 left-2 right-2 h-1.5")} + {handle("e", "right-0 top-2 bottom-2 w-1.5")} + {handle("w", "left-0 top-2 bottom-2 w-1.5")} + {handle("ne", "top-0 right-0 w-3 h-3")} + {handle("nw", "top-0 left-0 w-3 h-3")} + {handle("se", "bottom-0 right-0 w-3 h-3")} + {handle("sw", "bottom-0 left-0 w-3 h-3")} + {/* Header */} -
+

메신저

+