[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 붙여넣기로 대체)
206 lines
7.6 KiB
TypeScript
206 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "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";
|
|
|
|
const QUICK_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F44F}", "\u{1F64F}", "\u{1F525}"];
|
|
|
|
interface MessageItemProps {
|
|
message: Message;
|
|
isOwn: boolean;
|
|
showAvatar: boolean;
|
|
isLastInGroup: boolean;
|
|
}
|
|
|
|
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) {
|
|
return (
|
|
<div className={cn("flex gap-2 px-3 py-1", isOwn && "flex-row-reverse")}>
|
|
{showAvatar && !isOwn ? (
|
|
<UserAvatar photo={message.senderPhoto} name={message.senderName} size="sm" />
|
|
) : (
|
|
<div className="w-7" />
|
|
)}
|
|
<div className="text-xs text-muted-foreground italic">삭제된 메시지입니다</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const time = new Date(message.createdAt).toLocaleTimeString("ko-KR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
// 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"
|
|
)}
|
|
>
|
|
{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-1 shrink-0" />
|
|
)}
|
|
|
|
{/* 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="block text-xs font-medium text-muted-foreground mb-0.5">
|
|
{message.senderName}
|
|
</span>
|
|
)}
|
|
|
|
{/* 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 [overflow-wrap:anywhere]",
|
|
isOwn ? "bg-primary text-primary-foreground" : "bg-muted"
|
|
)}
|
|
>
|
|
{message.type === "file" && message.fileUrl ? (
|
|
<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"
|
|
>
|
|
{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 || "파일 다운로드"}
|
|
</button>
|
|
) : (
|
|
<span className="whitespace-pre-wrap">{message.content}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showActions && (
|
|
<div
|
|
className={cn(
|
|
"absolute top-0 flex items-center gap-0.5 bg-background border rounded-md shadow-sm px-1 py-0.5 -translate-y-1/2",
|
|
isOwn ? "left-0 -translate-x-full mr-1" : "right-0 translate-x-full ml-1"
|
|
)}
|
|
>
|
|
<button
|
|
onClick={() => setShowEmojiPicker((p) => !p)}
|
|
className="p-0.5 hover:bg-muted rounded"
|
|
>
|
|
<SmilePlus className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</button>
|
|
<button className="p-0.5 hover:bg-muted rounded">
|
|
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{showEmojiPicker && (
|
|
<div
|
|
className={cn(
|
|
"absolute top-0 -translate-y-full bg-background border rounded-md shadow-md p-1 flex gap-0.5 z-10",
|
|
isOwn ? "right-0" : "left-0"
|
|
)}
|
|
>
|
|
{QUICK_EMOJIS.map((emoji) => (
|
|
<button
|
|
key={emoji}
|
|
onClick={() => {
|
|
addReaction.mutate({ messageId: message.id, roomId: message.roomId, emoji });
|
|
setShowEmojiPicker(false);
|
|
}}
|
|
className="hover:bg-muted rounded p-0.5 text-base"
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{message.reactions.length > 0 && (
|
|
<div className={cn("flex flex-wrap gap-1 mt-0.5", isOwn ? "justify-end" : "justify-start")}>
|
|
{message.reactions.map((r) => (
|
|
<button
|
|
key={r.emoji}
|
|
onClick={() =>
|
|
addReaction.mutate({ messageId: message.id, roomId: message.roomId, emoji: r.emoji })
|
|
}
|
|
className="flex items-center gap-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs hover:bg-muted/80"
|
|
>
|
|
<span>{r.emoji}</span>
|
|
<span className="text-muted-foreground">{r.users.length}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* time — direct flex child, sibling of bubble column */}
|
|
{timeEl}
|
|
</div>
|
|
);
|
|
}
|