Files
vexplor_dev/frontend/components/messenger/RoomList.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

134 lines
5.7 KiB
TypeScript

"use client";
import { Plus, BellOff } from "lucide-react";
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 { cn } from "@/lib/utils";
import type { Room } from "@/hooks/useMessenger";
import type { UserStatus } from "@/hooks/useMessengerSocket";
function formatTime(dateStr?: string) {
if (!dateStr) return "";
const d = new Date(dateStr);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
return d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" });
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) return "어제";
return d.toLocaleDateString("ko-KR", { month: "short", day: "numeric" });
}
function RoomItem({ room, selected, onClick, currentUserId, userStatuses, isMuted }: { room: Room; selected: boolean; onClick: () => void; currentUserId?: string; userStatuses: Map<string, UserStatus>; isMuted: boolean }) {
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 grid items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left border-l-2 border-transparent",
selected && "bg-accent border-l-primary"
)}
style={{ gridTemplateColumns: "36px 1fr" }}
>
<UserAvatar
photo={avatarParticipant?.photo}
name={displayName || avatarParticipant?.userName || "?"}
size="md"
status={otherParticipant ? (userStatuses.get(otherParticipant.userId) ?? "offline") : undefined}
/>
<div className="min-w-0 overflow-hidden">
<div className="flex items-center gap-1 min-w-0">
<span className={cn("text-sm truncate min-w-0 flex-1", selected ? "font-semibold" : "font-medium")}>{displayName}</span>
<span className="text-[10px] text-muted-foreground shrink-0 whitespace-nowrap">
{formatTime(room.lastMessageAt)}
</span>
</div>
<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>
{isMuted && <BellOff className="h-3 w-3 text-muted-foreground shrink-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>
)}
</div>
</div>
</button>
);
}
export function RoomList({ userStatuses, newRoomOpen, setNewRoomOpen }: { userStatuses: Map<string, UserStatus>; newRoomOpen: boolean; setNewRoomOpen: (open: boolean) => void }) {
const { data: rooms = [] } = useRooms();
const { selectedRoomId, selectRoom, mutedRooms } = useMessengerContext();
const { user } = useAuth();
const dmRooms = rooms.filter((r) => r.type === "dm");
const groupRooms = rooms.filter((r) => r.type === "group");
const channelRooms = rooms.filter((r) => r.type === "channel");
const renderRooms = (list: Room[]) =>
list.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-4"> </div>
) : (
list.map((r) => (
<RoomItem
key={r.id}
room={r}
selected={r.id === selectedRoomId}
onClick={() => selectRoom(r.id)}
currentUserId={user?.userId}
userStatuses={userStatuses}
isMuted={mutedRooms.has(String(r.id))}
/>
))
);
return (
<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
onClick={() => setNewRoomOpen(true)}
className="p-1 hover:bg-muted rounded"
aria-label="새 대화"
>
<Plus className="h-4 w-4" />
</button>
</div>
<Tabs defaultValue="dm" className="flex-1 flex flex-col">
<TabsList className="w-full rounded-none border-b h-8 bg-transparent p-0">
<TabsTrigger value="dm" className="flex-1 text-xs h-8 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none">
DM
</TabsTrigger>
<TabsTrigger value="group" className="flex-1 text-xs h-8 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none">
</TabsTrigger>
<TabsTrigger value="channel" className="flex-1 text-xs h-8 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none">
</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1">
<TabsContent value="dm" className="m-0">{renderRooms(dmRooms)}</TabsContent>
<TabsContent value="group" className="m-0">{renderRooms(groupRooms)}</TabsContent>
<TabsContent value="channel" className="m-0">{renderRooms(channelRooms)}</TabsContent>
</ScrollArea>
</Tabs>
</div>
);
}