app/conversations/ 폴더에 page.tsx, layout.tsx, component폴더를 만든다.
page.tsx에는 useConversation()훅을 통해서 isOpen을 전달받아서 페이지가 열였을 때와 닫혔을 때의 상황을 구현해준다.
"use client";
import clsx from "clsx";
import useConversation from "../hooks/useConversation";
import Emptystate from "../components/EmptyState";
const Home = () => {
const { isOpen } = useConversation();
return (
<div
className={clsx("lg:pl-80 h-full lg:block", isOpen ? "block" : "hidden")}
>
<Emptystate />
</div>
);
};
export default Home;
그리고 lauout.tsx에 conversationlist컴포넌트를 Sidebar사이에 자리를 마련해놓는다
<Sidebar>
<div className="h-full">
<ConversationList initialItems={[]} />
{children}
</div>
</Sidebar>
ConversationList
이 컴포넌트는 사용자의 대화 목록을 사이드 바 형식으로 렌더링한다. 현재 활성화 된 대화를 강조하며, 새로운 그룹을 생헝하는 버튼도 포함한다. prop으로 받는 initialtems는 초기 대화 데이터 배열로 이 데이터는 서버로부터 받아오는 대화 정보를 포함한다.
대화 목록 관리
const [items, setItems] = useState(initialItems);
- 초기 대화 데이터를 items상태로 관리한다. 이후 새로운 대화가 추가되거나 삭제될 때 이 상태를 업데이트 할 수 있다.
- initialItems는 FullConversationType[]으로 이건 대화의 ID, 이름, 메시지 등이 포함된 객체 배열이다.
initialItems = [
{ id: "1", name: "Alice", messages: [...] },
{ id: "2", name: "Bob", messages: [...] }
];
useConversation훅을 활용한 대화 상태 관리
const { conversationId, isOpen } = useConversation();
- useConversation()훅은 현재 활성화된 대화의 ID(conversationID)를 가져오고, 대화 창이 열려있는지 여부(isOpen)을 확인한다.
- isOpen이 true면 사이브가 숨겨지고 false이면 사이드바가 화면 전체 너비로 표시된다.
대화목록 렌더링 부분
{items.map((item) => (
<ConversationBox
key={item.id}
data={item}
selected={conversationId === item.id}
/>
))}
- items.map을 통해 대화 데이터를 반복해서 ConversationBox컴포넌트를 렌더링한다.
- conversationBox에 전달된 Props는 data는 각 대화에 대한 데이터이고 selected는 현재 대화가 선택된 대화인지 연부를 나타내는 것이다.
컴포넌트의 전체 동작
- 초기 렌더링: initialItems를 기반으로 대화목록을 보여주고 현재 선택된 대화를 conversationId로 추적하여 스타일을 변경한다.
- 대화 선택 : useConversation훅을 통해 URL에서 선택된 대화를 동적으로 가져온다. 선택된 대화는 강조 표시가 되고 대화 창이 열릴 경우 사이드 바는 숨김 처리가 된다.
- 그룹 추가 버튼 : 버튼을 클릭하면 새로운 그룹 생성 모달을 띄울 수 있도록 확장이 가능하다.
ConversationBox컴포넌트
이 컴포넌트는 대화 목록의 각 항목을 렌더링 한다. 상대방 사용자의 정보, 마지막 메시지 내용, 읽음 여부를 표시한다. 클릭시 해당 대화 페이지로 이동한다. props으로는 data와 selected(현재 선택된 대화인지 여부를 파악)를 사용한다.
useOtherUser훅
const otherUser = useOtherUser(data);
- 대화데이터에서 상대방의 정보를 추출한다.
- 일반적으로 1:1대화라면 상대방은 data.users배열에서 로그인된 사용자가 아닌 유저로 판단한다.
useSession과 useRouter
const session = useSession();
const router = useRouter();
- useSession: 현재 로그인된 사용자의 세션 정보를 가져온다. 이를 통해 로그인한 사용자의 이메일(use.email)을 확인한다.
- useRouter: next.js의 라우팅 도구, 클릭시 해당 대화 페이지로 이동 할 수 있도록 라우터를 활용
클릭 이벤트 핸들러
const handleClick = useCallback(() => {
router.push(`/conversations/${data.id}`);
}, [data.id, router]);
- 대화 박스를 클릭하면 해당 대화의 상세 페이지(/conversations/:id)로 이동할 수 있다.
- useCallback은 불필요한 재렌더링 하는 것을 방지하는 것이다.
마지막 메시지 정보 가져오기
const lastMessage = useMemo(() => {
const messages = data.messages || [];
return messages[messages.length - 1];
}, [data.messages]);
- 대화데이터에서 가장 마지막 메시지를 추출한다. 메시지가 없으면 undefined를 반환한다.
사용자 이메일 가져오기
const userEmail = useMemo(() => {
return session.data?.user?.email;
}, [session.data?.user?.email]);
- 현재 로그인된 사용자의 이메일을 세션 정보에서 가져온다.
읽음 여부 판단
const hasSeen = useMemo(() => {
if (!lastMessage) return false;
const seenArray = lastMessage.seen || [];
if (!userEmail) return false;
return seenArray.filter((user) => user.email === userEmail).length !== 0;
}, [userEmail, lastMessage]);
- 마지막 메시지의 seen 필드를 확인하여 사용자가 읽었는지 여부를 판단한다.
- lastMessage.seen은 메시지를 읽은 사용자 목록을 의미한다.
- 로그인된 사용자의 이메일이 해당 목록에 포함되어 있으면 true를 반환한다.
마지막 메시지 텍스트 생성
const lastMessageText = useMemo(() => {
if (lastMessage?.image) {
return "Sent an image";
}
if (lastMessage?.body) {
return lastMessage.body;
}
return "Started a conversation";
}, [lastMessage]);
- 마지막 메시지의 내용에 따라 적절한 텍스트를 반환한다.
- 이미지가 첨부된 메시지라면 "Sent an image"이다.
- 메시지 본문이 존재하면 해당 텍스트를 반환한다.
- 메시지가 없는 경우에는 "Started a conversation"
동적 스타일링
className={clsx(
`
w-full
relative
flex
items-center
space-x-3
hover:bg-neutral-100
rounded-lg
transition
cursor-pointer
p-3
`,
selected ? "bg-neutral-100" : "bg-white"
)}
- clsx는 조건에 따라 동적으로CSS클래스를 추가한다.
- 선택된 대화(selected=true)는 배경색을 bg-neutral-100으로 설정한다.
- 기본 상태에서는 bg-white가 적용된다.
대화제목과 시간
<p className="text-md font-medium text-gray-900">
{data.name || otherUser.name}
</p>
{lastMessage?.createdAt && (
<p className="text-5x text-gray-400 font-light">
{format(new Date(lastMessage.createdAt), "p")}
</p>
)}
- 대화 제목: 대화이름(data.name)이 있으면 표시, 없으면 상대방의 이름(otherUser.name)을 표시한다.
- 마지막 메시지 시간: date-fns의 format함수를 사용하여 시간을 포맷팅한다.
useConversation
이 훅은 특정 조건에 따라 새로운 대화를 생성하거나 기존 대화를 반환하는 역할을 한다. 현재 사용자 정보를 가져오는 getCurrentUser함수를 활용한다.
전체 흐름
1. 현재 사용자 정보를 getCurrentUser를 호출하여 요청을 보낸 사용자의 정보를 가져온다.
2. 요청에서 JSON데이터를 추출하여 userId,isGroup, members, name값을 가져온다.
3. 그룹대화가 true일 경우 데이터 베이스에서 새로운 그룹 대화를 생성한다. 이것은 대화의 참여자 목록에 멤버와 현재 사용자를 포함한다. 생성된 그룹 대화를 JSON형태로 반환한다.
4. 1:1대화처리는 기존에 동일한 두 사용자가 포함된 대화가 있는지 확인 하고 이미 존재하면 해당 대화를 반환한다. 존재하지 않으면 새로운 1:1대화를 생성하고 반환한다.
5. 처리도중 에러가 발생하면 500에러를 반환한다.
현재 사용자 확인
const currentUser = await getCurrentUser();
if (!currentUser?.id || !currentUser?.email) {
return new NextResponse("Unauthorized", { status: 401 });
}
- 현재 사용자 정보가 없으면 권한이 없다는 의미로 401응답을 반환한다.
- getCurrentUser는 사용자의 인증 정보를 확인하는 커스텀 함수이다.
요청 본문 처리
const body = await request.json();
const { userId, isGroup, members, name } = body;
- 클라이언트에서 보낸 JSON데이터를 추출한다.
- 주요 데이터로 userId, isGroup, members, name이다.
그룹대화생성
if (isGroup && (!members || members.length < 2 || !name))
return new NextResponse("Unauthorized", { status: 400 });
- 그룹 대화를 생성하기 위해서는 다음 조건을 만족해야한다.
- 조건: members배열이 존재해야하고 members의 길이가 2명이상이어야하고 그룹이름이 있어야한다.
const newConversation = await prisma?.conversation.create({
data: {
name,
isGroup,
users: {
connect: [
...members.map((member: { value: string }) => ({
id: member.value,
})),
{
id: currentUser.id,
},
],
},
},
include: {
users: true,
},
});
return NextResponse.json(newConversation);
- 프리즈마를 사용하여 데이터베이스에 새로운 그룹대화를 생성한다.
- users필드는 Prisma의 관계설정으로 사용자를 연결한다.
- 그룹멤버와 현재 사용자를 모두 연결한다.
- 생성된 대화 데이터를 클라이언트로 반환한다.
1:1 대화확인 및 생성
const existingConversations = await prisma.conversation.findMany({
where: {
OR: [
{
userIds: {
equals: [currentUser.id, userId],
},
},
{
userIds: {
equals: [userId, currentUser.id],
},
},
],
},
});
const singleConversations = existingConversations[0];
if (singleConversations) {
return NextResponse.json(singleConversations);
}
- 기존에 현재 사용자와 상대방이 포함된 1:1대화가 있는지를 검색한다.
- userIds필드는 사용자의 ID를 배열로 저장하고 있으며, 이를 확인한다.
- 두사용자가 대화에 참여한 경우를 확인하기 위해 OR조건을 사용한다.
- 기존 대화가 있으면 해당 대화를 반환한다.
const newConversation = await prisma.conversation.create({
data: {
users: {
connect: [
{
id: currentUser.id,
},
{
id: userId,
},
],
},
},
include: {
users: true,
},
});
return NextResponse.json(newConversation);
- 기존 대화가 없으면 새로운 1:1대화를 생성한다.
- 두 사용자를 연결하여 대화에 참여시킨다.
getConversation
이 코드는 데이터베이스에서 현재 사용자가 참여하고 있는 대화목록을 가져오는 함수이다. prisma를 사용해 대화 데이터와 관련도니 사용자 및 메시지 정보를 포함하여 반환한다. getConversations함수는 대화 목록을 시간순으로 정렬하여 반환하며 에러가 발생하면 빈 배여을 반환한다.
현재 사용자 정보 가져오기
const currentUser = await getCurrentUser();
if (!currentUser?.id) {
return [];
}
- getCurrentUser를 호풀하여 현재 사용자의 정보를 가져온다.
- 현재 사용자의 ID가 없으면 빈 배열 [ ]을 반환하고 함수 실행을 종료한다.
- 이는 인증되지 않은 사용자의 접근을 막기 위한 기본적인 방어 코드이다.
prisma데이터베이스 조회
const conversations = await prisma.conversation.findMany({
orderBy: {
lastMessageAt: "desc",
},
where: {
userIds: {
has: currentUser.id,
},
},
include: {
users: true,
messages: {
include: {
sender: true,
seen: true,
},
},
},
});
정렬조건
- lastmessageAt필드를 기준으로 내림차순으로 대화를 정렬한다.
- 최신 메시지가 잇는 대화가 맨 위에 위치하도록 설정한다.
대화필터링
- userId는 대화에 참여한 사용자의 ID목록을 저장하는 필드이다.
- 현재 사용자의 ID가 포함된 대화만 조회한다.
포함된 데이터 설정
- include를 사용해 대화와 관련되 추가 데이터를 함께 가져온다.
- users: 대화에 참여한 사용자 정보
- messages: 대화에 포함된 메시지