From 0fa7b792ddf5e7496eb0d7cca0c98dbb751cca85 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Tue, 31 Mar 2026 15:13:46 +0900 Subject: [PATCH] =?UTF-8?q?[RAPID-fix]=20=EC=BA=A1=EC=B2=98=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=ED=81=AC=EA=B8=B0=20=EC=88=98=EC=A0=95:?= =?UTF-8?q?=20CSS=20=ED=94=BD=EC=85=80=20=EA=B8=B0=EC=A4=80=20scale=20fact?= =?UTF-8?q?or=EB=A1=9C=20=EC=A0=95=ED=99=95=ED=95=9C=20=EC=98=81=EC=97=AD?= =?UTF-8?q?=20=ED=81=AC=EB=A1=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [RAPID-fix] 캡처 속도/화질 개선 + 드래그 커서 4방향 화살표로 변경 - 오버레이 마운트 시점에 미리 캡처 시작 → mouseup 즉시 크롭 - scale: max(dpr, 2)로 화질 2배 향상 - 캡처 준비 중 wait 커서 표시 - 메신저 헤더 드래그 커서 cursor-grab → cursor-move (4방향 화살표) [RAPID-micro] 캡처 버튼 헤더 → 입력창 첨부파일 좌측으로 이동 [RAPID-micro] 채팅방 선택 시 스크롤 하단 이동 수정 [RAPID] 메신저 3가지 수정: 스크롤 버튼, DM 상대방 이름, 캡처 속도 개선 [RAPID-fix] 스크롤/캡처 3가지 수정 - 스크롤 하단 이동: useLayoutEffect → useEffect+rAF (이미지 레이아웃 완료 후 스크롤) - 스크롤 버튼: 리스너 deps를 room.id로 변경 (room 없을 때 미연결 문제 해결) - 캡처 속도: domToPng(느림) → getDisplayMedia 네이티브 API(즉시 캡처) [RAPID-fix] 메신저 3가지 수정 - 최신 메시지 버튼: 스크롤 컨테이너 밖으로 이동, 입력창 위 중앙 고정 - 스크롤 하단: rAF + 600ms 지연 2회 (이미지 비동기 로드 대응) - 캡처: 버튼 클릭 즉시 오버레이 + domToPng 병렬 실행, mouseup에서 await (font:false 최적화) [RAPID-micro] 채팅방 열 때 스크롤 점프 제거 [RAPID-fix] 캡처 오버레이 렌더 후 domToPng 시작으로 mousedown 딜레이 개선 [RAPID-fix] 캡처 프리캐싱: 메신저 열릴 때 백그라운드 domToPng → 버튼 클릭 즉시 오버레이 [RAPID-fix] 캡처 worker 추가: 리소스 fetch를 Web Worker로 오프로드 [RAPID-fix] 캡처 방식 변경: domToPng 제거 → getDisplayMedia (즉시 캡처, 프리캐싱 제거) [RAPID-micro] 화면 캡처 버튼 제거 (Cmd+V 붙여넣기로 대체) --- frontend/components/messenger/AuthImage.tsx | 57 +++++++++ frontend/components/messenger/ChatPanel.tsx | 59 +++++++-- .../components/messenger/ImageLightbox.tsx | 53 ++++++++ frontend/components/messenger/MessageItem.tsx | 111 ++++++++++++---- .../components/messenger/MessengerFAB.tsx | 9 +- .../components/messenger/MessengerModal.tsx | 29 +---- frontend/components/messenger/RoomList.tsx | 34 +++-- .../components/messenger/ScreenCapture.tsx | 120 ------------------ 8 files changed, 269 insertions(+), 203 deletions(-) create mode 100644 frontend/components/messenger/AuthImage.tsx create mode 100644 frontend/components/messenger/ImageLightbox.tsx delete mode 100644 frontend/components/messenger/ScreenCapture.tsx diff --git a/frontend/components/messenger/AuthImage.tsx b/frontend/components/messenger/AuthImage.tsx new file mode 100644 index 00000000..b550cb00 --- /dev/null +++ b/frontend/components/messenger/AuthImage.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { apiClient } from "@/lib/api/client"; +import { ImageLightbox } from "./ImageLightbox"; + +interface AuthImageProps { + src: string; + alt?: string; + className?: string; +} + +export function AuthImage({ src, alt, className }: AuthImageProps) { + const [blobUrl, setBlobUrl] = useState(null); + const [lightboxOpen, setLightboxOpen] = useState(false); + + useEffect(() => { + let objectUrl: string | null = null; + const path = src.replace(apiClient.defaults.baseURL ?? "", ""); + apiClient + .get(path, { responseType: "blob" }) + .then((res) => { + objectUrl = URL.createObjectURL(res.data); + setBlobUrl(objectUrl); + }) + .catch(() => setBlobUrl(null)); + + return () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [src]); + + if (!blobUrl) { + return ( +
+ ); + } + + return ( + <> + {alt setLightboxOpen(true)} + /> + {lightboxOpen && ( + setLightboxOpen(false)} + /> + )} + + ); +} diff --git a/frontend/components/messenger/ChatPanel.tsx b/frontend/components/messenger/ChatPanel.tsx index 3a66feb4..c3119ddc 100644 --- a/frontend/components/messenger/ChatPanel.tsx +++ b/frontend/components/messenger/ChatPanel.tsx @@ -1,22 +1,20 @@ "use client"; -import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { MessageSquare, Pencil, Check, X } from "lucide-react"; +import React, { useEffect, useRef, useState } from "react"; +import { MessageSquare, Pencil, Check, X, ChevronsDown } 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 { MessageInputHandle } from "./MessageInput"; import type { Room } from "@/hooks/useMessenger"; interface ChatPanelProps { room: Room | null; - messageInputRef?: React.RefObject; } -export function ChatPanel({ room, messageInputRef }: ChatPanelProps) { +export function ChatPanel({ room }: ChatPanelProps) { const { user } = useAuth(); const { selectedRoomId } = useMessengerContext(); const { data: messages } = useMessages(selectedRoomId); @@ -24,6 +22,8 @@ export function ChatPanel({ room, messageInputRef }: ChatPanelProps) { const updateRoom = useUpdateRoom(); const { emitTypingStart, emitTypingStop, typingUsers } = useMessengerSocket(); const scrollRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); + const [scrollReady, setScrollReady] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const [editName, setEditName] = useState(""); const editInputRef = useRef(null); @@ -36,10 +36,38 @@ export function ChatPanel({ room, messageInputRef }: ChatPanelProps) { const lastMessageId = messages?.[messages.length - 1]?.id; - useLayoutEffect(() => { + // Hide messages until scrolled to bottom (prevents visible jump on room open) + useEffect(() => { + setScrollReady(false); + }, [selectedRoomId]); + + // Scroll to bottom when messages load or room changes + // Two-pass: immediate rAF + 600ms delayed (for async image loads) + useEffect(() => { + requestAnimationFrame(() => { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + setScrollReady(true); + } + }); + const t = setTimeout(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, 600); + return () => clearTimeout(t); + }, [lastMessageId, selectedRoomId]); + + // Re-attach scroll listener whenever room changes (scrollRef mounts after room is set) + useEffect(() => { const el = scrollRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [lastMessageId]); + if (!el) return; + const onScroll = () => { + setIsAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 60); + }; + el.addEventListener("scroll", onScroll); + return () => el.removeEventListener("scroll", onScroll); + }, [room?.id]); if (!room) { return ( @@ -96,7 +124,7 @@ export function ChatPanel({ room, messageInputRef }: ChatPanelProps) { })(); return ( -
+
{/* Header */}
{isEditingName ? ( @@ -142,7 +170,7 @@ export function ChatPanel({ room, messageInputRef }: ChatPanelProps) {
{/* Messages */} -
+
{messages?.map((msg, idx) => (
@@ -174,9 +202,18 @@ export function ChatPanel({ room, messageInputRef }: ChatPanelProps) {
+ {!isAtBottom && ( + + )} + {/* Input */} emitTypingStart(room.id)} onTypingStop={() => emitTypingStop(room.id)} diff --git a/frontend/components/messenger/ImageLightbox.tsx b/frontend/components/messenger/ImageLightbox.tsx new file mode 100644 index 00000000..c2cb919d --- /dev/null +++ b/frontend/components/messenger/ImageLightbox.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { X, Download } from "lucide-react"; + +interface ImageLightboxProps { + src: string; + alt?: string; + downloadHref?: string; + onClose: () => void; +} + +export function ImageLightbox({ src, alt, downloadHref, onClose }: ImageLightboxProps) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + return ( +
+ {/* Controls */} +
e.stopPropagation()}> + {downloadHref && ( + + + + )} + +
+ + {/* Image */} + {alt e.stopPropagation()} + /> +
+ ); +} diff --git a/frontend/components/messenger/MessageItem.tsx b/frontend/components/messenger/MessageItem.tsx index da9074a1..1adc59c2 100644 --- a/frontend/components/messenger/MessageItem.tsx +++ b/frontend/components/messenger/MessageItem.tsx @@ -1,9 +1,10 @@ "use client"; import { useState } from "react"; -import { SmilePlus, MessageSquare, Download } from "lucide-react"; +import { SmilePlus, MessageSquare, Download, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { UserAvatar } from "./UserAvatar"; +import { AuthImage } from "./AuthImage"; import type { Message } from "@/hooks/useMessenger"; import { useAddReaction } from "@/hooks/useMessenger"; @@ -13,11 +14,14 @@ interface MessageItemProps { message: Message; isOwn: boolean; showAvatar: boolean; + isLastInGroup: boolean; } -export function MessageItem({ message, isOwn, showAvatar }: MessageItemProps) { +export function MessageItem({ message, isOwn, showAvatar, isLastInGroup }: MessageItemProps) { const [showActions, setShowActions] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [hovering, setHovering] = useState(false); + const [downloading, setDownloading] = useState(false); const addReaction = useAddReaction(); if (message.isDeleted) { @@ -38,49 +42,101 @@ export function MessageItem({ message, isOwn, showAvatar }: MessageItemProps) { minute: "2-digit", }); - return ( -
setShowActions(true)} - onMouseLeave={() => { - setShowActions(false); - setShowEmojiPicker(false); - }} + // Time element — sibling of bubble column in the outer flex row + // isOwn: appears left of bubble (flex-row-reverse puts it visually left) + // !isOwn: appears right of bubble + const timeEl = ( + - {showAvatar && !isOwn ? ( - + {time} + + ); + + return ( + // DOM order: [avatar, bubble column, timeEl] + // isOwn flex-row-reverse → visual: [timeEl, bubble column, avatar] + // !isOwn normal → visual: [avatar, bubble column, timeEl] +
+ {/* avatar */} + {!isOwn ? ( + showAvatar ? ( +
+ +
+ ) : ( +
+ ) ) : ( -
+
)} -
+ {/* bubble column — direct flex child: max-w-[65%] resolves against full chat panel width */} +
{ setShowActions(true); setHovering(true); }} + onMouseLeave={() => { setShowActions(false); setShowEmojiPicker(false); setHovering(false); }} + > {showAvatar && !isOwn && ( - + {message.senderName} )} -
+ {/* inline-block: sizes to content but constrained by parent max-w-[65%] */} +
+ {message.type === "file" && message.fileMimeType?.startsWith("image/") && message.fileUrl ? ( + + ) : (
{message.type === "file" && message.fileUrl ? ( - { + setDownloading(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const path = message.fileUrl!.replace(apiClient.defaults.baseURL ?? "", ""); + const res = await apiClient.get(path, { responseType: "blob" }); + const url = URL.createObjectURL(res.data); + const a = document.createElement("a"); + a.href = url; + a.download = message.fileName || "파일"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { + // download failed silently + } finally { + setDownloading(false); + } + }} + className="flex items-center gap-1.5 underline text-left disabled:opacity-60" > - + {downloading + ? + : + } {message.fileName || "파일 다운로드"} - + ) : ( {message.content} )}
+ )} {showActions && (
{message.reactions.length > 0 && ( -
+
{message.reactions.map((r) => (
+ + {/* time — direct flex child, sibling of bubble column */} + {timeEl}
); } diff --git a/frontend/components/messenger/MessengerFAB.tsx b/frontend/components/messenger/MessengerFAB.tsx index 15619ca3..c58bf7eb 100644 --- a/frontend/components/messenger/MessengerFAB.tsx +++ b/frontend/components/messenger/MessengerFAB.tsx @@ -2,16 +2,9 @@ import { MessageSquare } from "lucide-react"; import { useMessengerContext } from "@/contexts/MessengerContext"; -import { useUnreadCount } from "@/hooks/useMessenger"; -import { useEffect } from "react"; export function MessengerFAB() { - const { isOpen, openMessenger, unreadCount, setUnreadCount } = useMessengerContext(); - const { data: serverUnread } = useUnreadCount(); - - useEffect(() => { - if (serverUnread !== undefined) setUnreadCount(serverUnread); - }, [serverUnread, setUnreadCount]); + const { isOpen, openMessenger, unreadCount } = useMessengerContext(); if (isOpen) return null; diff --git a/frontend/components/messenger/MessengerModal.tsx b/frontend/components/messenger/MessengerModal.tsx index 4e0ce1b8..0e3f270b 100644 --- a/frontend/components/messenger/MessengerModal.tsx +++ b/frontend/components/messenger/MessengerModal.tsx @@ -1,14 +1,12 @@ "use client"; import { useState, useEffect, useRef, useCallback } from "react"; -import { X, Settings, Scissors } from "lucide-react"; +import { X, Settings } from "lucide-react"; import { useMessengerContext } from "@/contexts/MessengerContext"; 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; @@ -28,14 +26,13 @@ export function MessengerModal() { const { data: rooms = [] } = useRooms(); const { data: serverUnread } = useUnreadCount(); const [showSettings, setShowSettings] = useState(false); - const [capturing, setCapturing] = useState(false); - const messageInputRef = useRef(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; // Position & size state @@ -158,15 +155,6 @@ export function MessengerModal() { return ( <> - {capturing && ( - { - setCapturing(false); - messageInputRef.current?.addFiles([file]); - }} - onCancel={() => setCapturing(false)} - /> - )}
{/* Resize handles */} @@ -189,18 +177,11 @@ export function MessengerModal() { {/* Header */}

메신저

-