[RAPID-fix] 캡처 이미지 크기 수정: CSS 픽셀 기준 scale factor로 정확한 영역 크롭
[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 붙여넣기로 대체)
This commit is contained in:
57
frontend/components/messenger/AuthImage.tsx
Normal file
57
frontend/components/messenger/AuthImage.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<MessageInputHandle | null>;
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [scrollReady, setScrollReady] = useState(false);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
const editInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<div className="border-b px-4 py-2 flex items-center gap-2">
|
||||
{isEditingName ? (
|
||||
@@ -142,7 +170,7 @@ export function ChatPanel({ room, messageInputRef }: ChatPanelProps) {
|
||||
</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${scrollReady ? "" : " invisible"}`}>
|
||||
<div className="pt-2">
|
||||
{messages?.map((msg, idx) => (
|
||||
<div key={msg.id}>
|
||||
@@ -174,9 +202,18 @@ export function ChatPanel({ room, messageInputRef }: ChatPanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }}
|
||||
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" />
|
||||
최신 메시지
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
roomId={room.id}
|
||||
onTypingStart={() => emitTypingStart(room.id)}
|
||||
onTypingStop={() => emitTypingStop(room.id)}
|
||||
|
||||
53
frontend/components/messenger/ImageLightbox.tsx
Normal file
53
frontend/components/messenger/ImageLightbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<MessageInputHandle>(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 && (
|
||||
<ScreenCapture
|
||||
onCapture={(file) => {
|
||||
setCapturing(false);
|
||||
messageInputRef.current?.addFiles([file]);
|
||||
}}
|
||||
onCancel={() => setCapturing(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="fixed z-[9999] flex flex-col bg-background border rounded-lg shadow-2xl overflow-hidden"
|
||||
style={{
|
||||
@@ -174,7 +162,7 @@ export function MessengerModal() {
|
||||
top: pos.y,
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
display: isOpen && !capturing ? "flex" : "none",
|
||||
display: isOpen ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Resize handles */}
|
||||
@@ -189,18 +177,11 @@ export function MessengerModal() {
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2 border-b bg-muted/30 select-none cursor-grab active:cursor-grabbing"
|
||||
className="flex items-center justify-between px-4 py-2 border-b bg-muted/30 select-none cursor-move"
|
||||
onMouseDown={onHeaderMouseDown}
|
||||
>
|
||||
<h2 className="text-sm font-semibold">메신저</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCapturing(true)}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
aria-label="화면 캡처"
|
||||
>
|
||||
<Scissors className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettings((p) => !p)}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
@@ -221,7 +202,7 @@ export function MessengerModal() {
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 min-h-0 relative">
|
||||
<RoomList />
|
||||
<ChatPanel room={selectedRoom} messageInputRef={messageInputRef} />
|
||||
<ChatPanel room={selectedRoom} />
|
||||
|
||||
{showSettings && (
|
||||
<div className="absolute inset-0 bg-background z-10 flex flex-col">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
interface ScreenCaptureProps {
|
||||
onCapture: (file: File) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ScreenCapture({ onCapture, onCancel }: ScreenCaptureProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const startRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const [rect, setRect] = useState<Rect | null>(null);
|
||||
|
||||
// ESC to cancel
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onCancel]);
|
||||
|
||||
const getRect = (ax: number, ay: number, bx: number, by: number): Rect => ({
|
||||
x: Math.min(ax, bx),
|
||||
y: Math.min(ay, by),
|
||||
w: Math.abs(bx - ax),
|
||||
h: Math.abs(by - ay),
|
||||
});
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
setSelecting(true);
|
||||
setRect(null);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!selecting || !startRef.current) return;
|
||||
setRect(getRect(startRef.current.x, startRef.current.y, e.clientX, e.clientY));
|
||||
};
|
||||
|
||||
const handleMouseUp = async (e: React.MouseEvent) => {
|
||||
if (!selecting || !startRef.current) return;
|
||||
setSelecting(false);
|
||||
const r = getRect(startRef.current.x, startRef.current.y, e.clientX, e.clientY);
|
||||
startRef.current = null;
|
||||
|
||||
if (r.w < 4 || r.h < 4) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture via modern-screenshot
|
||||
try {
|
||||
const { domToPng } = await import("modern-screenshot");
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dataUrl = await domToPng(document.body, {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
style: {
|
||||
transform: "none",
|
||||
transformOrigin: "top left",
|
||||
},
|
||||
});
|
||||
|
||||
// Crop the captured region
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise((res) => { img.onload = res; });
|
||||
|
||||
const canvas = canvasRef.current!;
|
||||
canvas.width = r.w * dpr;
|
||||
canvas.height = r.h * dpr;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, r.x * dpr, r.y * dpr, r.w * dpr, r.h * dpr, 0, 0, r.w * dpr, r.h * dpr);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) { onCancel(); return; }
|
||||
const file = new File([blob], `capture-${Date.now()}.png`, { type: "image/png" });
|
||||
onCapture(file);
|
||||
}, "image/png");
|
||||
} catch {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 cursor-crosshair select-none"
|
||||
style={{ zIndex: 99999 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{/* instruction */}
|
||||
<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">
|
||||
드래그하여 캡처 영역을 선택하세요 · ESC로 취소
|
||||
</div>
|
||||
|
||||
{/* selection rect */}
|
||||
{rect && rect.w > 0 && rect.h > 0 && (
|
||||
<div
|
||||
className="absolute border-2 border-blue-400 bg-blue-400/10 pointer-events-none"
|
||||
style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user