Next.js 캐럿마켓 클론코딩(REALTIME CHAT - CodeChallenge)

짜스의 하루 ·2024년 9월 3일

두가지 코드 챌린지가 있다!

1 . 채팅 목록 만들기 (대화 상대 아바타, 이름, 최근 메시지 표시)
2 . 읽지 않은 메시지가 있다면 채팅 목록에 표시

채팅 목록 생성해보기

/chat 에 들어왔을 때
이렇게 채팅방 목록을 보여주었다.
상대방의 avatar, 상대방의 username, 최근 메시지를 주고 받은 시간, 최근 메시지 표시,
상대방이 보낸 메시지 중 내가 읽지 않은 메시지의 갯수 이렇게 보여주었다.

진짜 갈수록 시간이 너무 오래 걸리고 머리가 깨질 것 같고 그렇다.

하하하핳핫....

chat/page.tsx

import getSession from '@/lib/session';
import { formatToTime } from '@/lib/utils';
import { UserCircleIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import Link from 'next/link';
import { getChatRoom } from './actions';

export default async function Chat() {
  const chatRooms = await getChatRoom();
  const session = await getSession();

  return (
    <div className="mt-5 p-5 flex flex-col gap-5">
      <h1 className="text-center text-3xl font-semibold pb-10 border-orange-600">
        🥕당근을 흔들어주세요🥕
      </h1>
      {chatRooms.map((chatRoom, index) => (
        <Link
          key={chatRoom.id}
          href={`/chats/${chatRoom.id}`}
          className="block text-white relative"
        >
          <div
            className={`flex items-center mb-4 relative pb-4 ${
              index !== chatRooms.length - 1
                ? 'border-b-2 border-neutral-600'
                : ''
            }`}
          >
            {chatRoom.users
              .filter((user) => user.id !== session.id)
              .map((user) => (
                <div key={user.id} className="flex items-center mb-4">
                  <div className="flex-shrink-0">
                    {user.avatar ? (
                      <Image
                        src={user.avatar}
                        alt={user.username}
                        width={45}
                        height={45}
                        className="rounded-full"
                      />
                    ) : (
                      <UserCircleIcon className="w-10 h-10 text-gray-500" />
                    )}
                  </div>
                  <div className="ml-4">
                    {chatRoom.messages.map((message) => (
                      <div key={message.created_at.toString()}>
                        <div className="flex flex-row gap-3">
                          <div className="text-right">
                            <span className="block text-sm font-semibold">
                              {user.username}
                            </span>
                          </div>
                          <span></span>
                          <span className="block text-sm font-light text-neutral-300">
                            {formatToTime(message.created_at.toString())}
                          </span>
                        </div>
                        <span className="block text-lg text-neutral-200">
                          {message.payload}
                        </span>
                      </div>
                    ))}
                  </div>
                </div>
              ))}

            {chatRoom.unreadMessagesCount > 0 && (
              <div className="absolute right-0 bg-orange-600 text-white rounded-full text-xs font-semibold px-3 py-1.5">
                {chatRoom.unreadMessagesCount}
              </div>
            )}
          </div>
        </Link>
      ))}
    </div>
  );
}

getChatRoom()를 불러와서 chatRoom을 화면에 쫙 뿌려주었다. 여기서는 별 다를 건 겂고,

 {chatRoom.unreadMessagesCount > 0 && (
              <div className="absolute right-0 bg-orange-600 text-white rounded-full text-xs font-semibold px-3 py-1.5">
                {chatRoom.unreadMessagesCount}
              </div>
)}

이 부분 구현하는데 하루는 걸렸다(지피티 사랑해)

chat/actions.ts

export async function getUnreadMessagesCount(
  chatRoomId: string,
  userId: number
) {
  return await db.message.count({
    where: {
      chatRoomId,
      userId: {
        not: userId,
      },
      isRead: false,
    },
  });
}

export async function getChatRoom() {
  const session = await getSession();
  const userId = session.id;

  const chatRooms = await db.chatRoom.findMany({
    where: {
      users: {
        some: {
          id: userId,
        },
      },
    },
    select: {
      id: true,
      created_at: true,
      messages: {
        select: {
          payload: true,
          created_at: true,
        },
        take: 1,
        orderBy: { created_at: 'desc' },
      },
      users: {
        select: {
          id: true,
          username: true,
          avatar: true,
        },
      },
    },
  });

  const chatRoomsWithUnreadCount = await Promise.all(
    chatRooms.map(async (chatRoom) => {
      const unreadMessagesCount = await getUnreadMessagesCount(
        chatRoom.id,
        userId!
      );
      return {
        ...chatRoom,
        unreadMessagesCount,
      };
    })
  );

  return chatRoomsWithUnreadCount;
}

getChatRoom() 함수에서 사용자의 ID를 기반으로 채팅방을 검색한다. db.chatRoom.findMany()를 사용하여, 특정 사용자와 연관된 채팅방 목록을 가져오게 된다.

채팅방의 마지막 메시지 가져오기: 각 채팅방의 가장 최근 메시지를 가져오기 위해 messages 필드에서 take: 1을 사용하여 최신 메시지를 하나만 선택해 주었다. 메시지는 created_at 기준으로 내림차순으로 정렬시켜 주었다. (orderBy: { created_at: 'desc' }) .

여기서 깊게 생각해야 할 점은
상대방이 보낸 메시지 중, 내가 읽지 않은 메시지를 관리하기 위해서 채팅방 안에서 count, update를 활용해서 상대방이 보낸 메시지 중, isRead인 message를 저장하고, 만약 메시지를 읽었다면 isRead를 true로 변경하는 등, 이를 chat/page.tsx에서 받아서 화면에 뿌려준 것이다.

chats/[id]/actions.ts

export async function markMessagesAsRead(chatRoomId: string, userId: number) {
  await db.message.updateMany({
    where: {
      chatRoomId,
      userId: {
        not: userId,
      },
      isRead: false,
    },
    data: {
      isRead: true,
    },
  });
  revalidateTag('chat-message');
}

markMessagesAsRead 함수는 특정 채팅방(chatRoomId)에서 현재 사용자(userId)가 아닌 다른 사용자들이 보낸 모든 읽지 않은 메시지의 상태를 isRead: true로 업데이트하여 읽음 상태로 변경하게 된다.

where 조건에 의해, chatRoomId가 일치하고, userId가 현재 사용자가 아닌 메시지 중에서 isRead 상태가 false인 메시지들을 모두 업데이트를 하게 된다.
--> 이 함수는 isRead 상태를 true로 변경한다.

components/chat-message.tsx

여기서 간단하게 설명하자면, return 함수는 이 컴포넌트를 떠날 때, 수행할 함수를 정의해 놓는다. 여기서 이 컴포넌트를 떠나면서 markMessageAsRead를 호출하면서 isRead를 true 상태로 변경하게 된다.

다 읽고 갑니다~ 라고 이해하면 된다.

그리고 이걸 chat/actions.ts에서 getUnreadMessagesCount() 를 통해서 isRead : false인 messages의 count를 세는 것!
이 부분을 통해서 이제 갯수를 반환하는 것인데,

각 채팅방 객체(chatRoom)에 대해 getUnreadMessagesCount 함수를 호출하여 읽지 않은 메시지 수를 계산한다

각 채팅방의 기본 정보(chatRoom)와 함께 읽지 않은 메시지 수(unreadMessagesCount)를 포함하는 새로운 객체를 반환하게 된다.

이 객체는 { ...chatRoom, unreadMessagesCount } 형태로, 기존의 채팅방 정보를 유지하면서 추가로 unreadMessagesCount 속성을 추가한다!

자, 다시 설명하자면 지금 설명이 너무 뒤죽박죽 인 것 같은데,

isRead의 기본값은 false이다.

즉, chats/[id] 컴포넌트를 떠나면서 markMessagesAsRead 이게 호출되면서 봤던 messages 들은 isRead를 true로 update하고 떠나게 된다.

떠난 상태에서 새로운 메시지가 오면 그 메시지는 기본값이 false가 된다.
고로, isRead:false인 값을 새로 온 메시지의 값으로 판단하고,
getUnreadMessagesCount에서 isRead : false인 값을 갯수로 세면
"상대방이 보낸 메시지 중 내가 읽지 않은 메시지"가 되면서 ! 갯수를 셀 수 있는 것이다.

나도 이거 정리하면서 정확하게 이해하게 되부렀다 ㅋㅋ

아 욱겨


보너스 -> 화면 마지막으로 이동하기

이건 무슨 소리인가? 싶겠지만

보통 채팅 화면에 들어가면 (채팅 내용은 무시바람 ..ㅋㅋ)
이렇게 맨 밑부분인 입력창을 보여주곤 한다.

이걸 어떻게 구현하면 될까? 하고 찾아보고 지피티 친구에게 물어보니 useRef() 를 사용하면 된다고 한다.

const messagesEndRef = useRef<HTMLDivElement>(null);

useEffect(() => {
    // Scroll to the bottom of the chat when messages change
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

useRef는 React 훅으로, DOM 엘리먼트나 값을 참조할 때 사용된다.
messagesEndRef는 <div> 엘리먼트를 참조하기 위해 선언된 ref이다. 초기값으로 null을 설정.
--> 이 ref는 나중에 채팅 창의 맨 아래를 가리키는 <div> 엘리먼트를 참조하게 된다.

messagesEndRef.current는 ref가 참조하는 현재의 DOM 엘리먼트를 의미한다.
이 ref는 컴포넌트의 렌더링 후에 설정되므로, 이 ref를 통해 DOM 엘리먼트에 접근할 수 있다.

messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })scrollIntoView 메서드를 호출하여 참조된 <div> 엘리먼트가 화면에 보이도록 스크롤 한다.

behavior: 'smooth' 는 스크롤 애니메이션을 부드럽게 만들어준다.

useEffect 훅의 두 번째 인자로 [messages]가 전달되었는데, 이는 messages 배열이 변경될 때마다 이 useEffect 훅이 실행된다는 것을 의미한다.
--> 즉, 새로운 메시지가 추가되면 이 효과가 실행되어 채팅 창을 자동으로 스크롤하게 된다.

이 ref를 form 태그를 포함하고 있는 div에 추가해 주었다.

이렇게 하게 되면, 채팅방에 들어가면 자동으로 스크롤이 되어서 form 즉 입력창이 보이게 되고, 새로운 메시지를 보내게 되더라도, 자동 스크롤이 되어서 입력창이 자동으로 보이게 된다! 아주 굳!!!!!!!


매번 코드 챌린지를 하면서
재능이 없나 싶은 생각이 수만가지 든다 엄ㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁㅁ청 어렵고 힘들다 쿠쿠

profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글