From 0d020e260de47e45a142b3393cb623dfa2c31847 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Tue, 31 Mar 2026 15:36:57 +0900 Subject: [PATCH] =?UTF-8?q?[RAPID]=20=EB=A9=94=EC=8B=A0=EC=A0=80=203?= =?UTF-8?q?=EA=B0=80=EC=A7=80=20=EC=88=98=EC=A0=95:=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EB=B2=84=ED=8A=BC,=20DM=20=EC=83=81=EB=8C=80?= =?UTF-8?q?=EB=B0=A9=20=EC=9D=B4=EB=A6=84,=20=EC=BA=A1=EC=B2=98=20?= =?UTF-8?q?=EC=86=8D=EB=8F=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/messenger/AuthImage.tsx | 57 +++++++++ frontend/components/messenger/ChatPanel.tsx | 24 +++- .../components/messenger/ImageLightbox.tsx | 53 +++++++++ frontend/components/messenger/MessageItem.tsx | 111 +++++++++++++----- .../components/messenger/MessengerFAB.tsx | 9 +- .../components/messenger/MessengerModal.tsx | 25 +++- frontend/components/messenger/RoomList.tsx | 34 ++++-- .../components/messenger/ScreenCapture.tsx | 40 +------ 8 files changed, 267 insertions(+), 86 deletions(-) create mode 100644 frontend/components/messenger/AuthImage.tsx create mode 100644 frontend/components/messenger/ImageLightbox.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 b511448c..a51c56c3 100644 --- a/frontend/components/messenger/ChatPanel.tsx +++ b/frontend/components/messenger/ChatPanel.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { MessageSquare, Pencil, Check, X } from "lucide-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"; @@ -25,6 +25,7 @@ export function ChatPanel({ room, messageInputRef, onCaptureClick }: ChatPanelPr const updateRoom = useUpdateRoom(); const { emitTypingStart, emitTypingStop, typingUsers } = useMessengerSocket(); const scrollRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); const [isEditingName, setIsEditingName] = useState(false); const [editName, setEditName] = useState(""); const editInputRef = useRef(null); @@ -42,6 +43,16 @@ export function ChatPanel({ room, messageInputRef, onCaptureClick }: ChatPanelPr if (el) el.scrollTop = el.scrollHeight; }, [lastMessageId, selectedRoomId]); + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onScroll = () => { + setIsAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 60); + }; + el.addEventListener("scroll", onScroll); + return () => el.removeEventListener("scroll", onScroll); + }, []); + if (!room) { return (
@@ -143,7 +154,7 @@ export function ChatPanel({ room, messageInputRef, onCaptureClick }: ChatPanelPr
{/* Messages */} -
+
{messages?.map((msg, idx) => (
@@ -173,6 +184,15 @@ export function ChatPanel({ room, messageInputRef, onCaptureClick }: ChatPanelPr {roomTyping && roomTyping.length > 0 ? `${roomTyping.join(", ")}님이 입력 중...` : ""}
+ {!isAtBottom && ( + + )}
{/* Input */} 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 f0cfa1a9..ae372c83 100644 --- a/frontend/components/messenger/MessengerModal.tsx +++ b/frontend/components/messenger/MessengerModal.tsx @@ -29,6 +29,7 @@ export function MessengerModal() { const { data: serverUnread } = useUnreadCount(); const [showSettings, setShowSettings] = useState(false); const [capturing, setCapturing] = useState(false); + const capturedImgRef = useRef(null); const messageInputRef = useRef(null); useEffect(() => { @@ -38,6 +39,25 @@ export function MessengerModal() { const selectedRoom = rooms.find((r) => r.id === selectedRoomId) || null; + const handleCaptureClick = useCallback(async () => { + try { + const { domToPng } = await import("modern-screenshot"); + const scale = Math.max(window.devicePixelRatio || 1, 2); + const dataUrl = await domToPng(document.body, { + width: window.innerWidth, + height: window.innerHeight, + scale, + }); + const img = new Image(); + img.src = dataUrl; + await new Promise((res) => { img.onload = res; }); + capturedImgRef.current = img; + setCapturing(true); + } catch { + // fail silently + } + }, []); + // Position & size state const [pos, setPos] = useState({ x: 0, y: 0 }); const [size, setSize] = useState({ w: INIT_W, h: INIT_H }); @@ -158,8 +178,9 @@ export function MessengerModal() { return ( <> - {capturing && ( + {capturing && capturedImgRef.current && ( { setCapturing(false); messageInputRef.current?.addFiles([file]); @@ -214,7 +235,7 @@ export function MessengerModal() { {/* Body */}
- setCapturing(true)} /> + {showSettings && (
diff --git a/frontend/components/messenger/RoomList.tsx b/frontend/components/messenger/RoomList.tsx index d274a418..842c453d 100644 --- a/frontend/components/messenger/RoomList.tsx +++ b/frontend/components/messenger/RoomList.tsx @@ -6,6 +6,7 @@ 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"; @@ -25,33 +26,38 @@ function formatTime(dateStr?: string) { return d.toLocaleDateString("ko-KR", { month: "short", day: "numeric" }); } -function RoomItem({ room, selected, onClick }: { room: Room; selected: boolean; onClick: () => void }) { - const firstParticipant = room.participants[0]; +function RoomItem({ room, selected, onClick, currentUserId }: { room: Room; selected: boolean; onClick: () => void; currentUserId?: string }) { + const otherParticipant = room.type === "dm" + ? room.participants.find((p) => p.userId !== currentUserId) + : undefined; + const displayName = otherParticipant?.userName ?? room.name; + const avatarParticipant = otherParticipant ?? room.participants[0]; return (