Files
vexplor_dev/frontend/components/messenger/MessageItem.tsx
syc0123 403e5cae40 [RAPID] 메신저 기능 구현 및 UI/UX 개선
- 사용자 온라인 상태 표시 (온라인/자리비움/오프라인) 디스코드 스타일
- 채팅방별 알림 ON/OFF 토글 (Bell 아이콘, localStorage 저장)
- 파일 업로드 실시간 소켓 브로드캐스트 및 한글 파일명 깨짐 수정
- FAB 읽지않음 배지 버그 수정 (메신저 닫혀있을 때 markAsRead 차단)
- 타이핑 도트 애니메이션, 날짜 오늘/어제 표시
- 입력창 bordered box, DM 편집 버튼 숨김
- 메신저 설정 버튼 제거, 새 대화 시작하기 Empty state CTA
- useMessengerSocket 소켓 중복 생성 방지 (MessengerModal로 이동)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

[RAPID-micro] 추적 파일 정리 및 메신저 소소한 변경

- .omc/state/ 파일 git 추적 제거 (.gitignore 이미 설정됨)
- db/checkpoints/ gitignore 추가
- globals.css: 메신저 메시지 시간 폰트 스타일 추가
- useMessenger.ts: fileMimeType 필드 및 API_BASE_URL import 추가

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:20:43 +09:00

211 lines
7.9 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";
import { toast } from "sonner";
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 {
toast.error("파일 다운로드에 실패했습니다.");
} 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 opacity-50 cursor-not-allowed"
title="준비 중"
onClick={() => toast("답장 기능은 준비 중입니다.", { icon: "🚧" })}
>
<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>
);
}