[Nextjs]중고마켓 앱7 - 채팅 구현

Jungmin Ji·2024년 2월 27일
1

Nextjs

목록 보기
8/9

01. 채팅 페이지 UI 생성하기

  • /app/chat/ 디렉토리 생성
  • loading.tsx 복붙 생성
  • 클라이언트 컴포넌트인 ChatClient.tsx 생성

채팅 페이지 생성

/app/chat/page.tsx

import React from 'react'
import getCurrentUser from '../actions/getCurrentUser'
import ChatClient from './ ChatClient';

const ChatPage = async () => {
    const currentUser = await getCurrentUser();

  return (
    <div>
        <ChatClient currentUser={currentUser} />
    </div>
  )
}

export default ChatPage

채팅 레이아웃 UI

/app/chat/ChatCLient.tsx

"use client";
import { User } from "@prisma/client";
import axios from "axios";
import React, { useEffect, useState } from "react";

interface ChatClientProps {
  currentUser?: User | null;
}
const ChatClient = ({ currentUser }: ChatClientProps) => {
  const [receiver, setReceiver] = useState({
    receiverId: "",
    receiverName: "",
    receiverImage: "",
  });
  const [layout, setLayout] = useState(false);

  return (
    <main>
      <div className="grid grid-cols-[1fr] md:grid-cols-[300px_1fr]">
        {/* md보다 클때는 둘다 보여야함 */}
        {/* md보다 작고  layout이 true일때는 contact안보임 */}
        <section className={`md:flex ${layout && "hidden"}`}>
          Contact Component
        </section>
        <section className={`md:flex ${!layout && "hidden"}`}>
          Chat Component
        </section>
      </div>
    </main>
  );
};

export default ChatClient;

02. 채팅을 위한 스키마 작성하기

model User {
  id             String    @id @default(cuid())
  name           String?
  hashedPassword String?
  email          String?   @unique
  emailVerified  DateTime?
  image          String?
  accounts       Account[]
  sessions       Session[]
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt
  userType       UserType  @default(User)

  favoriteIds String[]
  products    Product[]

  conversations    Conversation[]
  sendMessages     Message[]      @relation("sender")
  receivedMessages Message[]      @relation("receiver")
}
...
model Conversation {
  id         String    @id @default(cuid())
  name       String?
  senderId   String
  receiverId String
  users      User[]
  messages   Message[]
  createdAt  DateTime  @default(now())
}

model Message {
  id             String       @id @default(cuid())
  createdAt      DateTime     @default(now())
  updatedAp      DateTime     @updatedAt
  text           String?
  image          String?
  sender         User         @relation(name: "sender", fields: [senderId], references: [id])
  senderId       String
  receiver       User         @relation(name: "receiver", fields: [receiverId], references: [id])
  receiverId     String
  conversation   Conversation @relation(fields: [conversationId], references: [id])
  conversationId String
}

npx prisma db push

03. 채팅을 구현하는 여러 가지 방법

여러가지

  • http polling <- 이거사용해서 채팅구현해볼것
  • websocket
  • redis
  • pusher ... 등등

04. Rest vs WebSocket

Rest

복잡한 네트워크에서 통신을 관리하기 위한 지침
Restful
유저가 요청을 보낼때만 서버가 응답하는 단방향 통신

브라우저가 서버에 요청 -> 새로고침 -> 데이터 받아옴

WebSocket

실시간 통신을 할때 주로 사용
서버가 요청을 받지 않아도 손님이나 드라이버에게 통신을 보낼 수 있음

즉시 데이터 받아옴

Rest API같은 경우는 계속 한방향으로 지속해서 요청을 보내야 데이터를 받을 수있다. 하지만 웹소켓의 경우 양방향으로 통신해서 바로 데이터를 받을 수 있음

05. Polling에 대해서

Polling

클라이언트가 일정한 간격으로 서버에 요청을 보내서 결과를 전달받는 방식
이방법은 구현이 쉽다는 장점이 있지만 서버의 상태가 변하지 않았을때도 계속해서 요청을 보내서 받아와야하기에 필요 없는 요청이 많아지며, 또한 요청 간격을 정하기도 쉽지 않다.

const POLL_TIME = 1000;

setInterval(() => {
	fetch('https://text.com/location');
}, POLL_TIME);
  • 폴링의 주기가 짧으면 서버의 성능에 부담
  • 주기가 길명 실시간성이 좋지 않음
  • 서버에 바뀐 데이터가 없어도 계속 요청을 해야하고 결과를 줘야함

Long polling

Polling의 단점으로 인해 새롭게 고안됨

롱폴링도 폴링처럼 계속 요청을 보냄
하지만 차이점은 일반 폴링은 주기적으로 요청을 보낸다면

롱폴링은 요청을 보내면 서버가 대기하고 있다가 이벤트가 발생하거나 타이아웃이 발생할때까지 기다린 후에 응답을 주게됨. 클라이언트를 응답을 다시 요청을 보내게됨

  • 서버의 상태 변화가 많이 없다면 폴링 방식보다 서버의 부담이 줄어들게된다.
  • 이런 특징으로 롱폴링은 실시간 메시지 전달이 중요하지만, 서버의 상태 변화가 자주 발생하지 않는 서비스에 적합하다.

스트리밍 Streaming

클라이언트에서 서버에 요청을 보내고 끊기지 않는 연결상태에서 계속 데이터를 수신한다.
양방향 소통보다는 서버에서 계속 요청을 받는것에 더 유용하다.

Polling, Long Polling, HTTP Streaming

Polling, Long Polling, HTTP Streaming 의 공통점은
결국 HTTP 프로토콜을 이용하며 이 HTTP 요청과 응답에 Header가 같이 전달되는데 이 Header에 많은 데이터가 들어있어서 너무 많은 요청과 응답의 교환은 부담을 주게된다.

HTTP 통신방법과 WebSocket의 차이점

웹소켓은 처음에 접속 확립을 위해서만 HTTP 프로토콜을 이용하고 그 이후부터는 독립적인 프로토콜 ws를 사용하게 된다. 또한 HTTP요청은 응답이 온 후 연결이 끊기게 되지만 웹소켓은 핸드쉐이크가 완료되고 임의로 연결을 끊기 전까지는 계속 연결이 되어있다.

06. 채팅을 위해 필요한 데이터 생성하기

ChatClient

api호출

api route 작성

/src/api/chat/route.ts

import getCurrentUser from "@/app/actions/getCurrentUser";
import { NextResponse } from "next/server";
import prisma from "@/helpers/prismadb";

export async function GET(request: Request) {
  const currentUser = await getCurrentUser();

  if (!currentUser) {
    return NextResponse.error();
  }

  const users = await prisma?.user.findMany({
    include: {
      conversations: {
        include: {
          messages: {
            include: {
              sender: true,
              receiver: true,
            },
            orderBy: {
              createdAt: "asc",
            },
          },
          users: true,
        },
      },
    },
  });

  return NextResponse.json(users);
}


요청이 왜 실패인지알수가 없음..

다른 api는 잘되는데 chat만 서버오류 -> currentUser없음... 로그인해야햇다.. !

07. SWR

SWR(stale-while-revalidate)이란

데이터를 가져오기 위한 React Hook 라이브러리
SWR은 원격데이터를 가져올 때 캐싱된 데이터가 있으면 그 데이터를 반환(stale)한 다음 요청(revalidate)을 보내고 마지막으로 최신 데이터와 함께 제공하는 라이브러리다.

https://swr.vercel.app/ko

SWR의 특징 및 장점

  • Lightweight

  • Realtime

  • Suspense

  • Pagination

  • Backend Agnostic

  • SSR / SSG Ready

  • TypeScript Ready

  • Remote + Local

  • 실시간 데이터를 표시해야 하는 경우

  • 페이지 로딩 속도를 높여야 하는 경우

  • 코드를 간결하고 명확하게 유지해야 하는 경우

  • 안정적인 데이터 페칭을 구현해야 하는 경우

  • 확장 가능한 데이터 페칭 솔루션이 필요한 경우
    유용함

사용법

설치 npm i swr

import useSWR from 'swr'
 
function Profile() {
  const { data, error, isLoading } = useSWR('/api/user', fetcher)
 
  if (error) return <div>failed to load</div>
  if (isLoading) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

swr이용해서 채팅데이터 가져오기

ChatClient.tsx

  const fetcher = (url: string) => axios.get(url).then((res) => res.data);
  const {data: users, error, isLoading} = useSWR('/api/chat', fetcher, {
    refreshInterval: 1000
  })
  console.log(users);

 const currentUserWithMessage = users?.find((user: TUserWithChat) => user.email === currentUser?.email)

현재 유저의 채팅데이터 찾기? -> 애초에 get할때 현재사용자의데이터만 가져오면 안되나?

TUserWithChat 타입은 src/types/index.ts에 설정

index.ts

import { Message, User } from "@prisma/client";

export type TUserWithChat = User & {
    conversations: TConversation[]
}

export type TConversation = {
    id: string;
    messages: Message[];
    users: User[];
}

08. 채팅 유저 나열하기


채팅관련 컴포넌트 파일을 위와같이 생성

ChatClient에서 컴포넌트 props내려주기

  return (
    <main>
      <div className="grid grid-cols-[1fr] md:grid-cols-[300px_1fr]">
        {/* md보다 클때는 둘다 보여야함 */}
        {/* md보다 작고  layout이 true일때는 contact안보임 */}
        <section className={`md:flex ${layout && "hidden"}`}>
          <Contacts 
            users={users}
            currentUser={currentUserWithMessage}
            setLayout={setLayout}
            setReceiver={setReceiver}
          />
        </section>
        <section className={`md:flex ${!layout && "hidden"}`}>
          <Chat />
        </section>
      </div>
    </main>
  );

Contacts 컴포넌트

src/components/chat/Contacts.tsx

import { TUserWithChat } from '@/types';
import React from 'react'
import User from './User';

interface ContactsProps {
  users: TUserWithChat[];
  currentUser: TUserWithChat;
  setLayout: (layout: boolean) => void;
  setReceiver: (receiver: {
    receiverId: string;
    receiverName: string;
    receiverImage: string;
  }) => void;
}
const Contacts = ({
  users,
  currentUser,
  setLayout,
  setReceiver
}: ContactsProps) => {

  const filterMessages = (userId: string, userName: string | null, userImage: string | null) => {
    setReceiver({
      receiverId: userId,
      receiverName: userName || "",
      receiverImage: userImage || "",
    });
  }
  return (
    <div className='w-full overflow-auto h-[calc(100vh_-_56px)] border-[1px]'>
      <h1 className='m-4 text-2xl font-semibold'>Chat</h1>
      <hr />
      <div className='flex flex-col'>
        {users.length > 0 && 
          users
          .filter((user) => user.id !== currentUser?.id)
          .map((user) => {
            return (
              <div
                key={user.id}
                onClick={() => {
                  filterMessages(user.id, user.name, user.image)
                  setLayout(true)
                }}
              >
                <User 
                  user={user}
                  currentUserId={currentUser?.id}
                />
              </div>
            );
          })
        }
      </div>
    </div>
  )
}

export default Contacts

09. 유저 컴포넌트 생성하기

대화 리스트의 컴포넌트인 User 컴포넌트 내용

  • 유저 이미지
  • 유저 이름
  • 유저와 마지막 대화
  • 마지막 대화 시간

마지막 대화 찾기

배열에서 slice(-1) 을 하면 마지막거 가져올수있음 이용

const A = [1,2,3,4]
A.slice(-1); 	// [4] 출력
A.slice(-1)[0]; // 4 출력

유저컴포넌트

src/components/chat/User.tsx

import { TConversation, TUserWithChat } from '@/types'
import React from 'react'
import Avatar from '../Avatar';
import { fromNow } from '@/helpers/dayjs';

interface UserProps {
  user: TUserWithChat;
  currentUserId: string;
}
const User = ({ user, currentUserId }: UserProps) => {

  // 유저간의 대화 conversations에서 currentUserId 
  const messagesWithCurrentUser = user.conversations.find(
    (conversation: TConversation) => 
      conversation.users.find((user) => user.id === currentUserId)
  );

  // 최근 메시지 1건 가져옴
  // 마지막대화에서 첫번째 인덱스 아이템 리턴
  const latestMessage = messagesWithCurrentUser?.messages.slice(-1)[0];
  return (
    <div className='grid grid-cols-[40px_1fr_50px] grid-rows-[40px] gap-3 py-3 px-4 border-b-[1px] hover:cursor-pointer hover:bg-orange-500'>
      <div>
        <Avatar src={user.image} />
      </div>
      <div>
        <h3>{user.name}</h3>
        {latestMessage && 
          <p className='overflow-hidden text-xs font-medium text-gray-600 break-words whitespace-pre-wrap'>{latestMessage.text}</p>
        }
        {latestMessage && latestMessage.image && 
          <p className='text-xs font-medium text-gray-600'>이미지</p>
        }
      </div>
      <div>
        {latestMessage && (
          <p>
            {fromNow(latestMessage.createdAt)}
          </p>
        )}
      </div>
    </div>
  )
}

export default User

10. 채팅 메세지를 위한 Input 컴포넌트 생성하기

Chat컴포넌트

import { TUserWithChat } from '@/types'
import React from 'react'
import Input from './Input';

interface ChatProps {
  currentUser: TUserWithChat;
  receiver: {
    receiverId: string,
    receiverName: string,
    receiverImage: string
  };
  setLayout: (layout: boolean) => void;
}
const Chat = ({
    currentUser,
    receiver,
    setLayout
}: ChatProps) => {
    if(!receiver.receiverName || !currentUser) {
        // return <div className='w-full h-full'></div>
    }
  return (
    <div className='w-full'>
        {/* chat header */}
        <div className=''>

        </div>
        {/* chat body */}
        <div className='flex flex-col gap-8 p-4 overflow-hidden h-[calc(100vh_-_60px-_-70px_-_80px)]'>

        </div>
        {/* input */}
        <div>
            <Input 
                receiverId={receiver?.receiverId}
                currentUserId={currentUser?.id}
            />
        </div>
    </div>
  )
}

export default Chat

Input 컴포넌트

import axios from 'axios';
import React, { FormEvent, useState } from 'react'
import { IoImageOutline } from 'react-icons/io5';
import { RiSendPlaneLine } from 'react-icons/ri';
import useSWR from 'swr';

interface InputProps {
  receiverId: string;
  currentUserId: string;
}

const Input = ({
  receiverId,
  currentUserId
}: InputProps) => {
  const [message, setMessage] = useState('');

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const imageUrl = '';
    if(message || imageUrl) {
      try {
        await axios.post('/api/chat', {
          text: message,
          image: imageUrl,
          receiverId: receiverId,
          senderId: currentUserId
        })
      }catch (error) {
        console.error(error);
      }
    }
    setMessage('');
  }
  return (
    <form 
      onSubmit={handleSubmit}
    className='relative flex items-center justify-between w-full gap-4 p-2 pl-4 border-[1px] border-gray-300 rounded-md shadow-sm'>
      <input
        className='w-full text-base outline-none'
        type='text'
        placeholder='메시지를 입력하세요.'
        value={message}
        onChange={(e) => setMessage(e.target.value)}
      />
      <div className='text-2xl text-gray-200 cursor-pointer'>
        <IoImageOutline />
      </div>
      <button 
        type='submit'
        className='flex items-center justify-center p-2 text-gray-900 bg-orange-500 rounded-lg cursor-pointer hover:bg-orange-600 disabled:opacity-60'
        >
        <RiSendPlaneLine  className='text-white'/>
      </button>
    </form>
  )
}

export default Input

11. 채팅 메세지를 위한 Route 생성하기

conversation 테이블, message테이블

A -> B 대화시
이미 대화했는지 체크 후
users는 conversation 생성할때 넣는 데이터
이미 대화했다면 conversation에는 안넣고 message에만 넣음
대화없다면 conversation과 message 생성

src/app/api/chat/route.ts

import getCurrentUser from "@/app/actions/getCurrentUser";
import { NextResponse } from "next/server";
import prisma from "@/helpers/prismadb";


export async function GET(request: Request) {
  const currentUser = await getCurrentUser();

  if (!currentUser) {
    return NextResponse.error();
  }

  const users = await prisma.user.findMany({
    include: {
      conversations: {
        include: {
          messages: {
            include: {
              sender: true,
              receiver: true,
            },
            orderBy: {
              createdAt: "asc",
            },
          },
          users: true,
        },
      },
    },
  });
  
  return NextResponse.json(users);
}

export async function POST(
  request: Request
) {
  const currentUser = await getCurrentUser();

  if(!currentUser) {
    return NextResponse.error();
  }

  const body = await request.json();

  // 이미 둘이 대화를 한 conversation이 있는지 찾기
  const conversation = await prisma.conversation.findFirst({
    where: {
      AND: [
        {
          users: {
            some: {
              id: body.senderId
            }
          }
        },
        {
          users: {
            some: {
              id: body.receiverId
            }
          }
        }
      ]
    }
  })

  // 이미 둘이 대화를 한 conversation 있다면
  // 
  if(conversation) {
    try {
      const message = await prisma.message.create({
        data: {
          text: body.text,
          image: body.image,
          senderId: body.senderId,
          receiverId: body.receiverId,
          conversationId: conversation.id
        }
      })
      
      return NextResponse.json(message);
    } catch (error) {
      return NextResponse.json(error)
    }
  } else {
    // 둘이 처음 대화하는거라면 Conversation과 Message 둘 다 생성
    const newConversation = await prisma.conversation.create({
      data: {
        senderId: body.senderId,
        receiverId: body.receiverId,
        users: {
          connect: [
            {
              id: body.senderId
            },
            {
              id: body.receiverId
            },
          ],
        },
      },
    });

    try {
      const message = await prisma.message.create({
        data: {
          text: body.text,
          image: body.image,
          senderId: body.senderId,
          receiverId: body.receiverId,
          conversationId: newConversation.id,
        },
      });

      return NextResponse.json(message);
    } catch (error) {
      return NextResponse.json(error);
    }
  }

}

12. ChatHeader 컴포넌트 생성하기

props

setLayout과 상대방 프로필정보와 상대방의 마지막 채팅시간

  const conversation = 
    currentUser?.conversations.find((conversation) => 
      conversation.users.find((user) => user.id === receiver.receiverId))
...
<ChatHeader
  setLayout={setLayout}
  receiverName={receiver.receiverName}
  receiverImage={receiver.receiverImage}
  lastMessageTime={
    conversation?.messages
    .filter((message) => message.receiverId === currentUser.id)
      .slice(-1)[0]?.createdAt
  }
/>

ChatHeader

import React from 'react'
import { IoChevronBackCircleSharp } from 'react-icons/io5';
import Avatar from '../Avatar';
import { formatTime } from '@/helpers/dayjs';

interface ChatHeaderProps {
  setLayout: (layout: boolean) => void;
  receiverName: string;
  receiverImage: string;
  lastMessageTime: Date | undefined;
}

const ChatHeader = ({
  setLayout, 
  receiverName,
  receiverImage,
  lastMessageTime
}: ChatHeaderProps) => {
  return (
    <div className="pl-4 border-b-[1px]">
      <div className="flex items-center h-16 gap-4">
        <div className="flex items-center justify-center text-3xl text-gray-400 hover:text-gray-600">
          <button onClick={() => setLayout(false)} className="md:hidden">
            <IoChevronBackCircleSharp />
          </button>
        </div>

        <div className="flex items-center gap-[0.6rem]">
          <div>
            <Avatar src={receiverImage} />
          </div>
          <h2 className="text-lg font-semibold">
            {receiverName}
            {lastMessageTime && <p>{formatTime(lastMessageTime)}</p>}
          </h2>
        </div>
      </div>
    </div>
  );
}

export default ChatHeader

13. Message 컴포넌트 생성하기

props

Chat.tsx

  const conversation = 
    currentUser?.conversations.find((conversation) => 
      conversation.users.find((user) => user.id === receiver.receiverId))
...
      <div className="flex flex-col gap-8 p-4 overflow-auto h-[calc(100vh_-_60px_-_70px_-_80px)]">
        {conversation && 
          conversation.messages.map((message) => {
            return (
              <Message 
                key={message.id}
                isSender={message.senderId === currentUser.id}
                messageText={message.text}
                messageImage={message.image}
                receiverName={receiver.receiverName}
                receiverImage={receiver.receiverImage}
                senderImage={currentUser?.image}
                time={message.createdAt}    
              />
            )
          })
        }
      </div>
      

Message

import React from 'react'
import Avatar from '../Avatar';
import { fromNow } from '@/helpers/dayjs';

interface MessageProps {
  isSender: boolean;
  messageText?: string | null;
  messageImage?: string | null;
  receiverName: string;
  receiverImage: string;
  senderImage: string | null;
  time: Date;
}

const Message = ({
  isSender,
  messageText,
  messageImage,
  receiverName,
  receiverImage,
  senderImage,
  time
}: MessageProps) => {
  // 로그인된 유저의 메시지는 오른쪽
  // 상대방의 메시지는 왼쪽
  return (
    <div
      className={`grid w-full grid-cols-[40px_1fr] gap-3 mx-auto`}
      style={{ direction: `${isSender ? 'rtl' : 'ltr'}` }}
    >
      <div>
        <Avatar src={senderImage && isSender ? senderImage : receiverImage} />
      </div>
      <div className='flex flex-col items-start justify-center'>
        <div className='flex items-center gap-2 mb-2 text-sm'>
          <span className='font-medium'>{isSender ? "You" : receiverName}</span>
          <span className="text-xs text-gray-500 opacity-80">
            {fromNow(time)}
          </span>
        </div>
        {messageText && (
          <div
            className={
              `p-2 break-all text-white rounded-lg
              ${ isSender ? "bg-orange-500 rounded-tr-none" : "bg-gray-400 rounded-tl-none"}`
            }
          >
            <p>{messageText}</p>
          </div>
        )}
      </div>
    </div>
  );
}

export default Message

메시지 작성시 자동 스크롤(하단으로 이동)

Chat컴포넌트에서 useRef객체 생성해서
렌더링이 될때마다 scrollToBottom()호출

  const messagesEndRef = useRef<null | HTMLDivElement>(null);

  const scrollToBottom = () => {
    messagesEndRef?.current?.scrollIntoView({
      behavior: "smooth"
    });
  }

  // 렌더링될때마다 메시지 스크롤
  useEffect(() => {
    scrollToBottom();
  });
  
...
      <div className="flex flex-col gap-8 p-4 overflow-auto h-[calc(100vh_-_60px_-_70px_-_80px)]">
        {conversation && 
          conversation.messages.map((message) => {
            return (
              <Message 
                key={message.id}
                isSender={message.senderId === currentUser.id}
                messageText={message.text}
                messageImage={message.image}
                receiverName={receiver.receiverName}
                receiverImage={receiver.receiverImage}
                senderImage={currentUser?.image}
                time={message.createdAt}    
              />
            )
          })
        }
        <div ref={messagesEndRef} />
      </div>

14. useSWRMutation

SWR(stale-while-revalidate)이란?

데이터를 가져오기 위한 React Hook 라이브러리이다.
SWR은 원격 데이터를 가져올때 캐싱된 데이터가 있으면 그 데이터를 먼저 반환(Stail)한 다음 요청(revalidate)를 보내고, 마지막으로 최신 데이터와 함께 제공하는 라이브러리이다.

채팅은 1초에 한번씩 데이터베이스에 요청을 해서 새로운 데이터를 가져옴
데이터베이스에 요청을 보내기전까지는 채팅이 바로 보여지지않음 -> 이문제 해결

원격데이터를 mutating,
SWR(캐시)를 이용해서 키에다가 값을 캐시를 시켜놓고
캐시가 있으면 그 데이터을 보여주고 다시 원격에서 데이터를 가져와서 최신데이터를 보여줌

https://swr.vercel.app/docs/mutation

특징 및 장점

  • 가벼움
  • Realtime
  • suspense
  • pagination
  • backend agnostic
  • SSR/SSG Ready
  • Typescript Ready
  • Remote + Local

사용법

  • Global Mutate
    useSWRConfig Hook사용
import { useSWRConfig } from "swr"
 
function App() {
  const { mutate } = useSWRConfig()
  mutate(key, data, options)
}
import { mutate } from "swr"
 
function App() {
  mutate(key, data, options)
}

위 두개 mutate는 key가 필요함

  • Bound Mutate
    이미 bound되어있기 때문에 key값 필요 없음
import useSWR from 'swr'
 
function Profile () {
  const { data, mutate } = useSWR('/api/user', fetcher)
 
  return (
    <div>
      <h1>My name is {data.name}.</h1>
      <button onClick={async () => {
        const newName = data.name.toUpperCase()
        // send a request to the API to update the data
        await requestUpdateUsername(newName)
        // update the local data immediately and revalidate (refetch)
        // NOTE: key is not required when using useSWR's mutate as it's pre-bound
        mutate({ ...data, name: newName })
      }}>Uppercase my name!</button>
    </div>
  )
}
  • useSWRMutation
    트리거를 호출해야지 mutate됨
import useSWRMutation from 'swr/mutation'
 
// Fetcher implementation.
// The extra argument will be passed via the `arg` property of the 2nd parameter.
// In the example below, `arg` will be `'my_token'`
async function updateUser(url, { arg }: { arg: string }) {
  await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${arg}`
    }
  })
}
 
function Profile() {
  // A useSWR + mutate like API, but it will not start the request automatically.
  const { trigger } = useSWRMutation('/api/user', updateUser, options)
 
  return <button onClick={() => {
    // Trigger `updateUser` with a specific argument.
    trigger('my_token')
  }}>Update User</button>
}

적용

chat/Input.tsx에서 기존에 axios이용하여 데이터 post하는 부분을 mutate로 변경할것임

const sendRequest = (url:string, {arg} : {
  arg: {
    text: string;
    image: string;
    receiverId: string;
    senderId: string;
  }
}) => {
  return fetch(url, {
    method: 'POST',
    body: JSON.stringify(arg)
  }).then(res => res.json());
}

const Input = ({
  receiverId,
  currentUserId
}: InputProps) => {
  ...
  const { trigger } = useSWRMutation('/api/chat', sendRequest)
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const imageUrl = '';
    if(message || imageUrl) {
      try {
        trigger({
          text: message,
          image: imageUrl,
          receiverId: receiverId,
          senderId: currentUserId
        })
        // await axios.post('/api/chat', {
        //   text: message,
        //   image: imageUrl,
        //   receiverId: receiverId,
        //   senderId: currentUserId
        // })
      }catch (error) {
        console.error(error);
      }
    }
    setMessage('');
  }

채팅을 보내자마자 바로 메시지 컴포넌트가 보이는것을 확인 할 수 있다.

15. 채팅 메세지 생성 시 이미지 업로드하기

file인풋과 이미지 ref 객체 등록

  // 이미지업로드 : file input에 ref등록
  const imageRef = useRef<HTMLInputElement>(null);

  const chooseImage = () => {
    imageRef.current?.click()
  }
  ...
      <input
        type="file"
        className="hidden"
        ref={imageRef}
        accept="image/*"
        multiple={false}
      />

이미지 프리뷰

이미지 올리면 url을 받아서 프리뷰라는 state에 넣어줌
메시지 input부분에 프리뷰를 보여준다.

previewImage 함수 작성

src/helperes/previewImage.ts

// 
const previewImage = (e: any, setImagePreview: any, setImage: any) => {

    console.log(e.target.files[0]);
}

export default previewImage;

const previewImage = (e: any, setImagePreview: any, setImage: any) => {
    // flie정보
    // console.log(e.target.files[0]);
    
    // file을 url로 변경
    const file = e.target.files[0];
    setImage(file); // cloudinary에 업로드할것
    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onloadend = () => {
      // 인코딩된 url
      // console.log(reader.result);
      setImagePreview(reader.result);
    }
}

export default previewImage;

Preview 관련 state 생성

const [image, setImage] = useState<File | null>(null); // cloudinary에 업로드할 이미지 데이터
  const [imagePreview, setImagePreview] = useState<string | null>(null); // preview할 이미지 데이터

Preview UI


      {imagePreview && (
        <div className='absolute right-0 w-full overflow-hidden rounded-md bottom-[4.2rem] max-w-[300px] shadow-md'>
          <img src={imagePreview} alt='' />
          <span className='absolute flex items-center justify-center p-2 text-xl text-white bg-gray-900 cursor-pointer top-[0.4rem] right-[0.4rem] rounded-full opacity-60 hover:opacity-100'>
            <CgClose />
          </span>
        </div>
      )}
      <input

preview 삭제

  const removeImage = () => {
    setImagePreview(null);
    setImage(null);
  }
  ...
    <span 
      onClick={() => removeImage()}
      className='absolute flex items-center justify-center p-2 text-xl text-white bg-gray-900 cursor-pointer top-[0.4rem] right-[0.4rem] rounded-full opacity-60 hover:opacity-100'>
    <CgClose />
  </span>

cloudynary에 이미지 업로드

uploadImage함수에서 cloudinary에 이미지 업로드한후
이미지 url은 db에 저장하기
src/helpers/uploadImages.ts

// cloudinary 업로드하기
const uploadImage = async (image: File) => {
    const url = `https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}/upload`;
    const formData = new FormData();

    formData.append('file', image);
    formData.append('upload_preset', process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET!);

    const response = await fetch(url, {
        method: 'POST',
        body: formData
    });

    const data = await response.json();
    console.log(data);
    return data.url;

}

export default uploadImage;

함수호출(url을 DB저장)

handleSubmit에서 호출하고 imageUrl에 값받기

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const imageUrl = image ? await uploadImage(image as File) : null;
    
    if (message || imageUrl) {
      try {
        trigger({
          text: message,
          image: imageUrl,
          receiverId: receiverId,
          senderId: currentUserId,
        });
        // await
      } catch (error) {
        console.error(error);
      }
    }
    setMessage("");
    setImagePreview(null);
    setImage(null);
  };

Message컴포넌트에 이미지 넣기

messageText 상단에 이미지내용 추가

        {messageImage && (
          <div className='overflow-hidden rounded-md mx-[0.6rem] max-w-[80%]'>
            <Image
              src={messageImage}
              width={300}
              height={300}
              alt=""
            />
          </div>
        )}


이런 에러가 났는데 next.config.js에 해당 도메인의 주소가 등록이 안됐다고한다.


http 프로토콜도 추가했다.

profile
FE DEV/디블리셔

0개의 댓글

관련 채용 정보