1. 상품 올린사람과 구매자와의 채팅방을 만들어보자
*prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
email String? @unique
password String?
phone String? @unique
github_id String? @unique
avatar String?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
tokens SMSToken[]
products Product[]
posts Post[]
comments Comment[]
likes Like[]
chat_rooms ChatRoom[]
messages Message[]
}
model SMSToken {
id Int @id @default(autoincrement())
token String @unique
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
}
model Product {
id Int @id @default(autoincrement())
title String
price Float
photo String
description String
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
}
model Post {
id Int @id @default(autoincrement())
title String
description String?
views Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
comments Comment[]
likes Like[]
}
model Comment {
id Int @id @default(autoincrement())
payload String
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
userId Int
postId Int
}
model Like {
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
userId Int
postId Int
@@id(name: "id", [userId, postId])
}
model ChatRoom {
id String @id @default(cuid())
users User[]
created_at DateTime @default(now())
updated_at DateTime @updatedAt
messages Message[]
}
model Message {
id Int @id @default(autoincrement())
payload String
created_at DateTime @default(now())
updated_at DateTime @updatedAt
room ChatRoom @relation(fields: [chatRoomId], references: [id])
user User @relation(fields: [userId], references: [id])
chatRoomId String
userId Int
}
1) ChatRoom과 Message 모델 생성
2) ChatRoom의 id는 cuid로 생성 (무작위) - 이유는 나중에 supabase 사용 시 실시간 서버 채널명이 고유이름이 되어야 하는데, 해당 ChatRoom id가 그 고유이름으로 쓰이게 된다.
--> 추가적으로 home/page.tsx, products/[id]/page.tsx, components/list-product.tsx 내용 일부 수정
*app/products/[id]/page.tsx
import db from "@/lib/db";
import { formatToWon } from "@/lib/utils";
import { UserIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import { notFound, redirect } from "next/navigation";
import {
unstable_cache as nextCache,
revalidatePath,
revalidateTag,
} from "next/cache";
import getSession from "@/lib/session";
async function getIsOwner(userId: number) {
// const session = await getSession();
// if (session.id) {
// return session.id === userId;
// }
return false;
}
async function getProduct(id: number) {
const product = await db.product.findUnique({
where: {
id,
},
include: {
user: {
select: {
username: true,
avatar: true,
},
},
},
});
return product;
}
const getCachedProduct = nextCache(getProduct, ["product-detail"], {
tags: ["product-detail"],
});
async function getProductTitle(id: number) {
const product = await db.product.findUnique({
where: {
id,
},
select: {
title: true,
},
});
return product;
}
const getCachedProductTitle = nextCache(getProductTitle, ["product-title"], {
tags: ["product-title"],
});
export async function generateMetadata({ params }: { params: { id: string } }) {
const product = await getCachedProductTitle(Number(params.id));
return {
title: product?.title,
};
}
export default async function ProductDetail({
params,
}: {
params: { id: string };
}) {
const id = Number(params.id);
if (isNaN(id)) {
return notFound();
}
const product = await getCachedProduct(id);
if (!product) {
return notFound();
}
const isOwner = await getIsOwner(product.userId);
const revalidate = async () => {
"use server";
revalidateTag("xxxx");
};
const createChatRoom = async () => {
"use server";
const session = await getSession();
const room = await db.chatRoom.create({
data: {
users: {
connect: [
{
id: product.userId,
},
{
id: session.id,
},
],
},
},
select: {
id: true,
},
});
redirect(`/chats/${room.id}`);
};
return (
<div className="pb-40">
<div className="relative aspect-square">
<Image
className="object-cover"
fill
src={`${product.photo}`}
alt={product.title}
/>
</div>
<div className="p-5 flex items-center gap-3 border-b border-neutral-700">
<div className="size-10 overflow-hidden rounded-full">
{product.user.avatar !== null ? (
<Image
src={product.user.avatar}
width={40}
height={40}
alt={product.user.username}
/>
) : (
<UserIcon />
)}
</div>
<div>
<h3>{product.user.username}</h3>
</div>
</div>
<div className="p-5">
<h1 className="text-2xl font-semibold">{product.title}</h1>
<p>{product.description}</p>
</div>
<div className="fixed w-full bottom-0 p-5 pb-10 bg-neutral-800 flex justify-between items-center max-w-screen-sm">
<span className="font-semibold text-xl">
{formatToWon(product.price)}원
</span>
{isOwner ? (
<form action={revalidate}>
<button className="bg-red-500 px-5 py-2.5 rounded-md text-white font-semibold">
Revalidate title cache
</button>
</form>
) : null}
<form action={createChatRoom}>
<button className="bg-orange-500 px-5 py-2.5 rounded-md text-white font-semibold">
채팅하기
</button>
</form>
</div>
</div>
);
}
export async function generateStaticParams() {
const products = await db.product.findMany({
select: {
id: true,
},
});
return products.map((product) => ({ id: product.id + "" }));
}
1) 채팅방으로 들어가는 부분 추가
*app/chats/[id]/page.tsx
import ChatMessagesList from "@/components/chat-messages-list";
import db from "@/lib/db";
import getSession from "@/lib/session";
import { Prisma } from "@prisma/client";
import { notFound } from "next/navigation";
async function getRoom(id: string) {
const room = await db.chatRoom.findUnique({
where: {
id,
},
include: {
users: {
select: { id: true },
},
},
});
if (room) {
const session = await getSession();
const canSee = Boolean(room.users.find((user) => user.id === session.id!));
if (!canSee) {
return null;
}
}
return room;
}
async function getMessages(chatRoomId: string) {
const messages = await db.message.findMany({
where: {
chatRoomId,
},
select: {
id: true,
payload: true,
created_at: true,
userId: true,
user: {
select: {
avatar: true,
username: true,
},
},
},
});
return messages;
}
async function getUserProfile() {
const session = await getSession();
const user = await db.user.findUnique({
where: {
id: session.id!,
},
select: {
username: true,
avatar: true,
},
});
return user;
}
export type InitialChatMessages = Prisma.PromiseReturnType<typeof getMessages>;
export default async function ChatRoom({ params }: { params: { id: string } }) {
const room = await getRoom(params.id);
if (!room) {
return notFound();
}
const initialMessages = await getMessages(params.id);
const session = await getSession();
const user = await getUserProfile();
if (!user) {
return notFound();
}
return (
<ChatMessagesList
chatRoomId={params.id}
userId={session.id!}
username={user.username}
avatar={user.avatar!}
initialMessages={initialMessages}
/>
);
}
1) 채팅방 세부 화면
2) 세션을 통해, 채팅방 입장 시 본인 인지 검증
*components/chat-messages-list.tsx
"use client";
import { InitialChatMessages } from "@/app/chats/[id]/page";
import { saveMessage } from "@/app/chats/actions";
import { formatToTimeAgo } from "@/lib/utils";
import { ArrowUpCircleIcon } from "@heroicons/react/24/solid";
import { RealtimeChannel, createClient } from "@supabase/supabase-js";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
const SUPABASE_PUBLIC_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJmaHZzbHpsbnp6eXRmd21jd3dzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTE1MzIwMDYsImV4cCI6MjAyNzEwODAwNn0.6_CBGkLSb8hl06prsZkzsUrf98pjwcwgXgqeFLNiXL0";
const SUPABASE_URL = "https://bfhvslzlnzzytfwmcwws.supabase.co";
interface ChatMessageListProps {
initialMessages: InitialChatMessages;
userId: number;
chatRoomId: string;
username: string;
avatar: string;
}
export default function ChatMessagesList({
initialMessages,
userId,
chatRoomId,
username,
avatar,
}: ChatMessageListProps) {
const [messages, setMessages] = useState(initialMessages);
const [message, setMessage] = useState("");
const channel = useRef<RealtimeChannel>();
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { value },
} = event;
setMessage(value);
};
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setMessages((prevMsgs) => [
...prevMsgs,
{
id: Date.now(),
payload: message,
created_at: new Date(),
userId,
user: {
username: "string",
avatar: "xxx",
},
},
]);
channel.current?.send({
type: "broadcast",
event: "message",
payload: {
id: Date.now(),
payload: message,
created_at: new Date(),
userId,
user: {
username,
avatar,
},
},
});
await saveMessage(message, chatRoomId);
setMessage("");
};
useEffect(() => {
const client = createClient(SUPABASE_URL, SUPABASE_PUBLIC_KEY);
channel.current = client.channel(`room-${chatRoomId}`);
channel.current
.on("broadcast", { event: "message" }, (payload) => {
setMessages((prevMsgs) => [...prevMsgs, payload.payload]);
})
.subscribe();
return () => {
channel.current?.unsubscribe();
};
}, [chatRoomId]);
return (
<div className="p-5 flex flex-col gap-5 min-h-screen justify-end">
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-2 items-start ${
message.userId === userId ? "justify-end" : ""
}`}
>
{message.userId === userId ? null : (
<Image
src={message.user.avatar!}
alt={message.user.username}
width={50}
height={50}
className="size-8 rounded-full"
/>
)}
<div
className={`flex flex-col gap-1 ${
message.userId === userId ? "items-end" : ""
}`}
>
<span
className={`${
message.userId === userId ? "bg-neutral-500" : "bg-orange-500"
} p-2.5 rounded-md`}
>
{message.payload}
</span>
<span className="text-xs">
{formatToTimeAgo(message.created_at.toString())}
</span>
</div>
</div>
))}
<form className="flex relative" onSubmit={onSubmit}>
<input
required
onChange={onChange}
value={message}
className="bg-transparent rounded-full w-full h-10 focus:outline-none px-5 ring-2 focus:ring-4 transition ring-neutral-200 focus:ring-neutral-50 border-none placeholder:text-neutral-400"
type="text"
name="message"
placeholder="Write a message..."
/>
<button className="absolute right-0">
<ArrowUpCircleIcon className="size-10 text-orange-500 transition-colors hover:text-orange-300" />
</button>
</form>
</div>
);
}
1) npm install @supabase/supabase-js 설치, supabase에는 실시간 서버 서비스가 있다. 프로세스는
A와 B가 있을 경우, A가 supabase에 실시간 서버를 만들면 B는 거기에 들어가고, 해당 실시간 서버에서 주고받는 데이터는 그 실시간 서버방에 있는 사람들끼리 send할 수 있는 것이다. 나의 서비스에는 이 데이터를 실시간으로 화면에 보여줄 수 있고, 새로고침하면 해당 데이터가 사라지기 때문에 해당 값을 db에 저장해주어야 새로고침했을 때도 데이터가 그대로 화면에 보여지게 된다.
2) 처음 유저가 해당 방에 들어오면, useEffect를 통해 supabase 고유 채널을 useRef를 통해 channel 이름으로 선언해준다. 그리고 subscribe 해준다. (만약 해당 페이지가 닫히면 unsubscribe됨)
이 때, setMessages를 통해 supabase 실시간 서버에서 들어오는 payload값이 실시간으로 업데이트 되게 해준다.
3) 이전 useOptimistic과 비슷하게 form input 값을 onSubmit하게 되면 setMessages값에 db로 가기전에 데이터 값을 화면에 보여준 후, 위에 연결한 supabase 채널에 데이터를 send해주고 db에 값을 저장한다. 그리고 setMessage값을 ("") 해준다. (채팅 입력 칸 초기화)
*app/chats/actions.ts
"use server";
import db from "@/lib/db";
import getSession from "@/lib/session";
export async function saveMessage(payload: string, chatRoomId: string) {
const session = await getSession();
await db.message.create({
data: {
payload,
chatRoomId,
userId: session.id!,
},
select: { id: true },
});
}
1) db에 메시지를 저장해주어야지, 채팅방 새로고침 후 초기 데이터를 전달 할 수 있다.
***추가로 아래를 만들어야 한다. (다른 학우분들꺼 참조)
1. 채팅 목록 만들기 (대화 상대 아바타, 이름, 최근 메시지 표시)
2. 읽지 않은 메시지가 있다면 채팅 목록에 표시
'코딩강의 > [풀스택]캐럿마켓 클론코딩(노마드코더)' 카테고리의 다른 글
#17 NextJS Extras (0) | 2024.08.10 |
---|---|
#16 Live Streaming (0) | 2024.08.09 |
#14 Optimistic Updates (0) | 2024.08.07 |
#13 Caching (0) | 2024.08.04 |
#12 Modals (0) | 2024.08.01 |