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: 대화에 포함된 메시지 

 

Messenger앱에서 처음에는 로그인 페이지를 만드는 것을 진행했다. 

 

프로젝트 시작 

일단 처음에는 아래의 명령어를 사용해서 프로젝트를 시작했다. 리액트의 create-react-app과 다를 건 없다. 

npm create-next-app --typescript

typescript를 사용할 것이기에 --typescript를 붙여서 명령어를 작성했다. 

 

생성된 프로젝트의 파일구조를 확인해보면 app파일에는 page.tsx와 layout.tsx파일이 존재한다.

page.tsx

page.tsx는 각 페이지의 메인 콘텐츠를 정의하는 파일이다. next.js에서 page.tsx파일은 URL경로와 직접 연결이 되는데 예를 들어, app/about/page.tsx파일이 있다면 /about경로에 대응하는 페이지가 생성된다. 이 파일에는 해당 페이지에서만 필요한 컴포넌트와 콘텐츠가 들어가고, React컴포넌트로 작성된다. 또한 이곳에 API데이터를 가져오거나 페이지 렌더링과 관련된 로직을 추가할 수 도있다.

 

layout.tsx

layout.tsx는 페이지의 공통 레이아웃을 정의하는 파일이다. 여기에는 모든 페이지에서 공통으로 사용되는 UI요소를 포함할 수 있다. layout.tsx파일은 특정 폴더에 위치한 모든 페이지에 적용되는 레이아웃 역할을 하며, page.tsx에서 정의된 개별 컨텐츠가 layout.tsx의 children을 통해서 삽입된다. 예를 들어 app/about/layout.tsx파일이 있다면 about폴더의 모든 페이지에 이 레이아웃이 적용된다.

 

이 프로젝트에서는 app/(site)/page.tsx 형태로 진행이 되었다. 여기서 page.tsx는 index의 역할을 하게된다. 

 

AuthForm.tsx

이 파일은 app/(site)/components/AuthForm.tsx에 위치한다. 

 

클라이언트 컴포넌트

이 페이지의 상단에 "use client"라는 녀석이 있는데 클라이언트 컴포넌트라는 친구이다. 

이 친구는 서버에서 렌더링하는 대신브라우저에서 실행되는 컴포넌트로 , React의 상태와 훅을 사용할 수 있다. 브라우저의 동적인 상호작용을 필요로 할 때 사용된다. 

 

리터럴 타입

type Variant = "LOGIN" | "REGISTER";

이건 typescript에서 타입을 별도로 지정하는 문법이다. 여기서는 variant타입이 지정되면 LOGIN값 또는 REGISTER 중 하나의 값만 가질 수 있게 된다. 이러한 경우에는 코드의 가독성이 좋아지고 잘못된 값을 갖지 않도록 타입검사를 해주는 역할도 한다. 

 

상태관리

variant: 현재 컴포넌트가 로그인 상태인지 회원가입 상태인지를 관리합니다.

isLoading: 요청 중인 상태를 나타내며, 로딩이 완료될 때까지 일부 UI요소를 비활성화합니다.

 

toggleVariant()

useCallback을 사용하여 variant상태를 토글하는 함수이다. useCallback을 사용해서 variant상태를 토글한다.

- 로그인 중이면 회원가입 상태를 변경하고, 회원가입 상태라면 로그인 상태로 변경한다. 

- variant를 의존성으로 지정해서 variant가 변경될 때만 새로 생성되도록 한다. 

const toggleVariant = useCallback(() => {
  if (variant === "LOGIN") {
    setVariant("REGISTER");
  } else {
    setVariant("LOGIN");
  }
}, [variant]);

 

FieldValues

- FieldValues는 폼의 입력 데이터 형태를 정의하는 TypeScript 타입입니다.

- 각 폼 필드가 어떤 데이터를 포함할 수 있는지 유연하게 지정할 수 있도록 합니다. FieldValues를 기본 타입으로 사용하여 폼 입력 필드의 다양한 값을 저장할 수 있습니다

- 예시 { name: string, email: string, password: string } 등의 형태로 입력 데이터가 저장될 수 있습니다.

 

SubmitHandler

- 폼이 제출되었을 때 호출되는 함수의 타입을 정의하는 타입이다.

- 폼 제출 이벤트에 맞는 데이터 타입을 보장하기 위해 사용된다. 폼의 데이터 타입이 FieldValues일 때 submitHandler로 이 타입을  설정해주면, 폼 제출 시 해당 데이터가 onSubmit함수의 매개변수로 전달된다.

 

useForm

- useForm은 react-hook-form라이브러리 훅으로, 폼 데이터를 다루고 유효성 검사를 돕는다. 

- register는 입력필드를 useForm에 등록하며, handleSubmit는 폼제출을 처리한다. 

- formState.error는 입력값 유효성 검사 오류를 담고 있다. 

- defaultValues를 통해 폼 초기 값을 지정한다. 

const { register, handleSubmit, formState: { errors } } = useForm<FieldValues>({
  defaultValues: { name: "", email: "", password: "" },
});

 

onSubmit함수 

handlesubmit로  호출되는 폼 제출함수이다. 

- variant가 register면 회원가입 login이면 로그인 처리한다. 

const onSubmit: SubmitHandler<FieldValues> = (data) => {
  setIsLoading(true);
  if (variant === "REGISTER") {
    // 회원가입 로직 추가
  }
  if (variant === "LOGIN") {
    // 로그인 로직 추가
  }
};

SubmitHandler에 대해서 공부해보자.. 이게 뭘까

 

JSX렌더링 부분

여긴 뭐 전부다 설명할 필요는 없을 거 같다. 

중요하거나 새로운 부분들만 정리.

 

사용된 tailwind CSS

#전체 레이아웃 컨테이너
mt-8: 상단 여백 (margin-top) 적용.
sm:mx-auto: 작은 화면 이상에서 수평 중앙 정렬.
sm:w-full: 작은 화면 이상에서 너비 100%.
sm:max-w-md: 작은 화면 이상에서 최대 너비 md (768px)

#내부 카드 레이아웃
bg-white: 배경색을 흰색으로 설정.
px-4: 좌우 패딩 1rem.
py-8: 상하 패딩 2rem.
shadow: 기본 그림자 효과.
sm:rounded-lg: 작은 화면 이상에서 큰 모서리 반경.
sm:px-10: 작은 화면 이상에서 좌우 패딩을 2.5rem로 설정.

#폼 레이아웃
space-y-6: 각 자식 요소 사이에 수직 여백 1.5rem 적용

#입력 필드 상단 구분선
relative: 상대 위치 설정.
flex: Flexbox로 레이아웃 설정.
justify-center: 가운데 정렬.
text-sm: 텍스트 크기를 작게 설정.

#구분선
w-full: 너비 100%.
border-t: 상단 테두리만 적용.
border-gray-300: 회색 톤의 테두리 색상 적용.

#버튼 스타일
mt-6: 상단 여백 1.5rem 적용.
flex: Flexbox 레이아웃 설정.
gap-2: Flexbox 자식 요소 간 0.5rem 간격 설정.

#로그인/회원가입 전환링크
flex: Flexbox로 레이아웃 설정.
gap-2: 요소 간 간격을 0.5rem 적용.
justify-center: 자식 요소를 가운데 정렬.
text-sm: 텍스트 크기를 작게 설정.
mt-6: 상단 여백 1.5rem 적용.
px-2: 좌우 패딩 0.5rem 적용.
text-gray-500: 회색 톤의 텍스트 색상.

#전환링크 스타일
underline: 텍스트 밑줄 추가.
cursor-pointer: 마우스 커서를 포인터 모양으로 설정.

 

+ Recent posts