[RAPID] 메신저 3가지 수정: 스크롤 버튼, DM 상대방 이름, 캡처 속도 개선

This commit is contained in:
2026-03-31 15:36:57 +09:00
parent e832e1afff
commit 0d020e260d
8 changed files with 267 additions and 86 deletions

View File

@@ -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<string | null>(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 (
<div className={`bg-muted animate-pulse rounded ${className ?? "w-40 h-40"}`} />
);
}
return (
<>
<img
src={blobUrl}
alt={alt || "이미지"}
className={`${className ?? ""} cursor-pointer`}
onClick={() => setLightboxOpen(true)}
/>
{lightboxOpen && (
<ImageLightbox
src={blobUrl}
alt={alt}
downloadHref={blobUrl}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
);
}

View File

@@ -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<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState("");
const editInputRef = useRef<HTMLInputElement>(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 (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2">
@@ -143,7 +154,7 @@ export function ChatPanel({ room, messageInputRef, onCaptureClick }: ChatPanelPr
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto relative">
<div className="pt-2">
{messages?.map((msg, idx) => (
<div key={msg.id}>
@@ -173,6 +184,15 @@ export function ChatPanel({ room, messageInputRef, onCaptureClick }: ChatPanelPr
{roomTyping && roomTyping.length > 0 ? `${roomTyping.join(", ")}님이 입력 중...` : ""}
</div>
</div>
{!isAtBottom && (
<button
onClick={() => { scrollRef.current!.scrollTop = scrollRef.current!.scrollHeight; }}
className="absolute bottom-16 right-4 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" />
</button>
)}
</div>
{/* Input */}

View File

@@ -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 (
<div
className="fixed inset-0 z-[99999] flex items-center justify-center bg-black/80"
onClick={onClose}
>
{/* Controls */}
<div className="absolute top-4 right-4 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{downloadHref && (
<a
href={downloadHref}
download
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white"
>
<Download className="h-5 w-5" />
</a>
)}
<button
onClick={onClose}
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Image */}
<img
src={src}
alt={alt || "이미지"}
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
);
}

View File

@@ -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 (
<div
className={cn("group flex gap-2 px-3 py-0.5", isOwn && "flex-row-reverse")}
onMouseEnter={() => 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 = (
<span
className={cn(
"messenger-time text-muted-foreground shrink-0 self-end mt-auto pb-0.5 whitespace-nowrap transition-opacity",
isLastInGroup || hovering ? "opacity-100" : "opacity-0"
)}
>
{showAvatar && !isOwn ? (
<UserAvatar photo={message.senderPhoto} name={message.senderName} size="sm" />
{time}
</span>
);
return (
// DOM order: [avatar, bubble column, timeEl]
// isOwn flex-row-reverse → visual: [timeEl, bubble column, avatar]
// !isOwn normal → visual: [avatar, bubble column, timeEl]
<div className={cn("flex items-start gap-2 px-3 py-0.5", isOwn && "flex-row-reverse")}>
{/* avatar */}
{!isOwn ? (
showAvatar ? (
<div className="mt-0.5 shrink-0">
<UserAvatar photo={message.senderPhoto} name={message.senderName} size="sm" />
</div>
) : (
<div className="w-7 shrink-0" />
)
) : (
<div className="w-7" />
<div className="w-1 shrink-0" />
)}
<div className={cn("flex flex-col max-w-[70%]", isOwn ? "items-end" : "items-start")}>
{/* bubble column — direct flex child: max-w-[65%] resolves against full chat panel width */}
<div
className={cn("max-w-[65%]", isOwn ? "text-right" : "text-left")}
onMouseEnter={() => { setShowActions(true); setHovering(true); }}
onMouseLeave={() => { setShowActions(false); setShowEmojiPicker(false); setHovering(false); }}
>
{showAvatar && !isOwn && (
<span className="text-xs font-medium text-muted-foreground mb-0.5">
<span className="block text-xs font-medium text-muted-foreground mb-0.5">
{message.senderName}
</span>
)}
<div className="relative">
{/* inline-block: sizes to content but constrained by parent max-w-[65%] */}
<div className="relative inline-block max-w-full text-left">
{message.type === "file" && message.fileMimeType?.startsWith("image/") && message.fileUrl ? (
<AuthImage
src={message.fileUrl}
alt={message.fileName || "이미지"}
className="max-w-[240px] max-h-[240px] rounded-lg object-contain block"
/>
) : (
<div
className={cn(
"rounded-lg px-3 py-1.5 text-sm break-words",
"rounded-lg px-3 py-1.5 text-sm [overflow-wrap:anywhere]",
isOwn ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{message.type === "file" && message.fileUrl ? (
<a
href={message.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 underline"
<button
disabled={downloading}
onClick={async () => {
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"
>
<Download className="h-3.5 w-3.5" />
{downloading
? <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
: <Download className="h-3.5 w-3.5 shrink-0" />
}
{message.fileName || "파일 다운로드"}
</a>
</button>
) : (
<span className="whitespace-pre-wrap">{message.content}</span>
)}
</div>
)}
{showActions && (
<div
@@ -125,7 +181,7 @@ export function MessageItem({ message, isOwn, showAvatar }: MessageItemProps) {
</div>
{message.reactions.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
<div className={cn("flex flex-wrap gap-1 mt-0.5", isOwn ? "justify-end" : "justify-start")}>
{message.reactions.map((r) => (
<button
key={r.emoji}
@@ -140,9 +196,10 @@ export function MessageItem({ message, isOwn, showAvatar }: MessageItemProps) {
))}
</div>
)}
<span className="text-[10px] text-muted-foreground mt-0.5">{time}</span>
</div>
{/* time — direct flex child, sibling of bubble column */}
{timeEl}
</div>
);
}

View File

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

View File

@@ -29,6 +29,7 @@ export function MessengerModal() {
const { data: serverUnread } = useUnreadCount();
const [showSettings, setShowSettings] = useState(false);
const [capturing, setCapturing] = useState(false);
const capturedImgRef = useRef<HTMLImageElement | null>(null);
const messageInputRef = useRef<MessageInputHandle>(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 && (
<ScreenCapture
capturedImg={capturedImgRef.current}
onCapture={(file) => {
setCapturing(false);
messageInputRef.current?.addFiles([file]);
@@ -214,7 +235,7 @@ export function MessengerModal() {
{/* Body */}
<div className="flex flex-1 min-h-0 relative">
<RoomList />
<ChatPanel room={selectedRoom} messageInputRef={messageInputRef} onCaptureClick={() => setCapturing(true)} />
<ChatPanel room={selectedRoom} messageInputRef={messageInputRef} onCaptureClick={handleCaptureClick} />
{showSettings && (
<div className="absolute inset-0 bg-background z-10 flex flex-col">

View File

@@ -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 (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left",
"w-full grid items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left",
selected && "bg-accent"
)}
style={{ gridTemplateColumns: "36px 1fr" }}
>
<UserAvatar
photo={firstParticipant?.photo}
name={room.name || firstParticipant?.userName || "?"}
photo={avatarParticipant?.photo}
name={displayName || avatarParticipant?.userName || "?"}
size="md"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium truncate">{room.name}</span>
<span className="text-[10px] text-muted-foreground shrink-0 ml-1">
<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="text-[10px] text-muted-foreground shrink-0 whitespace-nowrap">
{formatTime(room.lastMessageAt)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground truncate">
<div className="flex items-center gap-1 min-w-0">
<span className="text-xs text-muted-foreground truncate min-w-0 flex-1">
{room.lastMessage || "\u00A0"}
</span>
{room.unreadCount > 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}
</span>
@@ -65,6 +71,7 @@ function RoomItem({ room, selected, onClick }: { room: Room; selected: boolean;
export function RoomList() {
const { data: rooms = [] } = useRooms();
const { selectedRoomId, selectRoom } = useMessengerContext();
const { user } = useAuth();
const [newRoomOpen, setNewRoomOpen] = useState(false);
const dmRooms = rooms.filter((r) => r.type === "dm");
@@ -81,12 +88,13 @@ export function RoomList() {
room={r}
selected={r.id === selectedRoomId}
onClick={() => selectRoom(r.id)}
currentUserId={user?.userId}
/>
))
);
return (
<div className="flex flex-col h-full border-r w-[240px] shrink-0">
<div className="flex flex-col h-full border-r w-[240px] shrink-0 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> </span>
<button

View File

@@ -10,43 +10,17 @@ interface Rect {
}
interface ScreenCaptureProps {
capturedImg: HTMLImageElement;
onCapture: (file: File) => void;
onCancel: () => void;
}
export function ScreenCapture({ onCapture, onCancel }: ScreenCaptureProps) {
export function ScreenCapture({ capturedImg, onCapture, onCancel }: ScreenCaptureProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const capturedImgRef = useRef<HTMLImageElement | null>(null);
const [ready, setReady] = useState(false);
const [selecting, setSelecting] = useState(false);
const startRef = useRef<{ x: number; y: number } | null>(null);
const [rect, setRect] = useState<Rect | null>(null);
// Pre-capture on mount so mouseup is instant
useEffect(() => {
let cancelled = false;
(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,
});
if (cancelled) return;
const img = new Image();
img.src = dataUrl;
await new Promise((res) => { img.onload = res; });
capturedImgRef.current = img;
setReady(true);
} catch {
if (!cancelled) onCancel();
}
})();
return () => { cancelled = true; };
}, [onCancel]);
// ESC to cancel
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -82,9 +56,7 @@ export function ScreenCapture({ onCapture, onCancel }: ScreenCaptureProps) {
if (r.w < 4 || r.h < 4) { onCancel(); return; }
const img = capturedImgRef.current;
if (!img) { onCancel(); return; }
const img = capturedImg;
const scaleX = img.naturalWidth / window.innerWidth;
const scaleY = img.naturalHeight / window.innerHeight;
@@ -110,13 +82,13 @@ export function ScreenCapture({ onCapture, onCancel }: ScreenCaptureProps) {
<canvas ref={canvasRef} className="hidden" />
<div
className="fixed inset-0 bg-black/40 select-none"
style={{ zIndex: 99999, cursor: ready ? "crosshair" : "wait" }}
onMouseDown={ready ? handleMouseDown : undefined}
style={{ zIndex: 99999, cursor: "crosshair" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-black/70 text-white text-sm px-4 py-2 rounded-full pointer-events-none">
{ready ? "드래그하여 캡처 영역을 선택하세요" : "캡처 준비 중..."}&nbsp;·&nbsp;ESC로
&nbsp;·&nbsp;ESC로
</div>
{rect && rect.w > 0 && rect.h > 0 && (