- 사용자 온라인 상태 표시 (온라인/자리비움/오프라인) 디스코드 스타일 - 채팅방별 알림 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>
175 lines
6.0 KiB
TypeScript
175 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { useCompanyUsers, useCreateRoom } from "@/hooks/useMessenger";
|
|
import { useMessengerContext } from "@/contexts/MessengerContext";
|
|
import { UserAvatar } from "./UserAvatar";
|
|
import { Check } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import type { UserStatus } from "@/hooks/useMessengerSocket";
|
|
|
|
interface NewRoomModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
userStatuses: Map<string, UserStatus>;
|
|
}
|
|
|
|
export function NewRoomModal({ open, onOpenChange, userStatuses }: NewRoomModalProps) {
|
|
const [tab, setTab] = useState<"dm" | "group" | "channel">("dm");
|
|
const [search, setSearch] = useState("");
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [roomName, setRoomName] = useState("");
|
|
const [channelDesc, setChannelDesc] = useState("");
|
|
|
|
const { data: users = [] } = useCompanyUsers();
|
|
const createRoom = useCreateRoom();
|
|
const { selectRoom } = useMessengerContext();
|
|
|
|
const filtered = users.filter(
|
|
(u) =>
|
|
u.userName.toLowerCase().includes(search.toLowerCase()) ||
|
|
(u.deptName && u.deptName.toLowerCase().includes(search.toLowerCase()))
|
|
);
|
|
|
|
const toggleUser = (userId: string) => {
|
|
if (tab === "dm") {
|
|
setSelectedIds([userId]);
|
|
} else {
|
|
setSelectedIds((prev) =>
|
|
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
try {
|
|
const room = await createRoom.mutateAsync({
|
|
type: tab,
|
|
name: tab === "channel" ? roomName : tab === "group" ? roomName : undefined,
|
|
description: tab === "channel" ? channelDesc : undefined,
|
|
participantIds: selectedIds,
|
|
});
|
|
selectRoom(room.id);
|
|
onOpenChange(false);
|
|
reset();
|
|
} catch {
|
|
// handled by query
|
|
}
|
|
};
|
|
|
|
const reset = () => {
|
|
setSearch("");
|
|
setSelectedIds([]);
|
|
setRoomName("");
|
|
setChannelDesc("");
|
|
};
|
|
|
|
const canCreate =
|
|
selectedIds.length > 0 &&
|
|
(tab === "dm" || tab === "group" || (tab === "channel" && roomName.trim()));
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>새 대화</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<Tabs value={tab} onValueChange={(v) => { setTab(v as typeof tab); reset(); }}>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="dm" className="flex-1">DM</TabsTrigger>
|
|
<TabsTrigger value="group" className="flex-1">그룹</TabsTrigger>
|
|
<TabsTrigger value="channel" className="flex-1">채널</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{(tab === "group" || tab === "channel") && (
|
|
<div className="mt-3 space-y-2">
|
|
<Input
|
|
placeholder={tab === "group" ? "그룹 이름" : "채널 이름"}
|
|
value={roomName}
|
|
onChange={(e) => setRoomName(e.target.value)}
|
|
/>
|
|
{tab === "channel" && (
|
|
<Input
|
|
placeholder="채널 설명 (선택)"
|
|
value={channelDesc}
|
|
onChange={(e) => setChannelDesc(e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<TabsContent value={tab} className="mt-3">
|
|
<Input
|
|
placeholder="사용자 검색..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="mb-2"
|
|
/>
|
|
|
|
{selectedIds.length > 0 && tab !== "dm" && (
|
|
<div className="flex flex-wrap gap-1 mb-2">
|
|
{selectedIds.map((id) => {
|
|
const u = users.find((x) => x.userId === id);
|
|
return u ? (
|
|
<span
|
|
key={id}
|
|
onClick={() => toggleUser(id)}
|
|
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary text-xs px-2 py-0.5 cursor-pointer hover:bg-primary/20"
|
|
>
|
|
{u.userName} ×
|
|
</span>
|
|
) : null;
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<ScrollArea className="h-60">
|
|
{filtered.map((u) => {
|
|
const selected = selectedIds.includes(u.userId);
|
|
return (
|
|
<button
|
|
key={u.userId}
|
|
onClick={() => toggleUser(u.userId)}
|
|
className={cn(
|
|
"w-full flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-left",
|
|
selected && "bg-accent"
|
|
)}
|
|
>
|
|
<UserAvatar photo={u.photo} name={u.userName} size="sm" status={userStatuses.get(u.userId) ?? "offline"} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{u.userName}</div>
|
|
{u.deptName && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{u.deptName} {u.positionName && `/ ${u.positionName}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<div className="flex justify-end mt-2">
|
|
<Button onClick={handleCreate} disabled={!canCreate || createRoom.isPending} size="sm">
|
|
{createRoom.isPending ? "생성 중..." : "대화 시작"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|