#10 DMs
> 채팅방을 만들어보자
메시지를 보내려고 할 때 기존 채팅방이 없으면 새로 만들어주고, 있으면 기존 채팅방에 메시지를 보내는 방법이다.
- get_room.sql
CREATE FUNCTION public.get_room(from_user_id uuid, to_user_id uuid)
RETURNS TABLE (message_room_id bigint)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
RETURN QUERY SELECT m1.message_room_id
FROM message_room_members m1
INNER JOIN message_room_members m2
ON m1.message_room_id = m2.message_room_id
WHERE m1.profile_id = from_user_id
AND m2.profile_id = to_user_id;
END;
$$;
- mutations.ts
export const sendMessage = async (
client: SupabaseClient<Database>,
{
fromUserId,
toUserId,
content,
}: { fromUserId: string; toUserId: string; content: string }
) => {
const { data, error } = await client
.rpc("get_room", {
from_user_id: fromUserId,
to_user_id: toUserId,
})
.maybeSingle();
if (error) {
throw error;
}
if (data?.message_room_id) {
await client.from("messages").insert({
message_room_id: data.message_room_id,
sender_id: fromUserId,
content,
});
return data.message_room_id;
} else {
const { data: roomData, error: roomError } = await client
.from("message_rooms")
.insert({})
.select("message_room_id")
.single();
if (roomError) {
throw roomError;
}
await client.from("message_room_members").insert([
{
message_room_id: roomData.message_room_id,
profile_id: fromUserId,
},
{
message_room_id: roomData.message_room_id,
profile_id: toUserId,
},
]);
await client.from("messages").insert({
message_room_id: roomData.message_room_id,
sender_id: fromUserId,
content,
});
return roomData.message_room_id;
}
};
참고로 .insert({}) 이거는 빈방을 만드는 개념이다.
- send-message-page.tsx
import { makeSSRClient } from "~/supa-client";
import type { Route } from "./+types/send-message-page";
import { getLoggedInUserId, getUserProfile } from "../queries";
import { sendMessage } from "../mutations";
import { z } from "zod";
import { redirect } from "react-router";
const formSchema = z.object({
content: z.string().min(1),
});
export const action = async ({ request, params }: Route.ActionArgs) => {
if (request.method !== "POST") {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
const formData = await request.formData();
const { client } = makeSSRClient(request);
const fromUserId = await getLoggedInUserId(client);
const { profile_id: toUserId } = await getUserProfile(client, {
username: params.username,
});
const messageRoomId = await sendMessage(client, {
fromUserId,
toUserId,
content: formData.get("content") as string,
});
return redirect(`/my/messages/${messageRoomId}`);
};
중간 api 페이지
> 채팅방 rooms 부분 데이터 가져오기
- messages-view.sql
CREATE OR REPLACE VIEW messages_view AS
SELECT
m1.message_room_id,
profiles.name,
(
SELECT content
FROM messages
WHERE message_room_id = m1.message_room_id
ORDER BY message_id DESC
LIMIT 1
) AS last_message,
m1.profile_id AS profile_id,
m2.profile_id AS other_profile_id,
profiles.avatar
FROM message_room_members m1
INNER JOIN message_room_members m2 ON m1.message_room_id = m2.message_room_id
INNER JOIN profiles ON profiles.profile_id = m2.profile_id;
- queries.ts
export const getMessages = async (
client: SupabaseClient<Database>,
{ userId }: { userId: string }
) => {
const { data, error } = await client
.from("messages_view")
.select("*")
.eq("profile_id", userId)
.neq("other_profile_id", userId);
if (error) {
throw error;
}
return data;
};
- messages-layout.tsx
import { Outlet } from "react-router";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarMenu,
SidebarProvider,
} from "~/common/components/ui/sidebar";
import MessageRoomCard from "../components/message-room-card";
import { Route } from "./+types/messages-layout";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId, getMessages } from "../queries";
export const loader = async ({ request }: Route.LoaderArgs) => {
const { client } = await makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const messages = await getMessages(client, { userId });
return {
messages,
};
};
export default function MessagesLayout({ loaderData }: Route.ComponentProps) {
return (
<SidebarProvider className="flex max-h-[calc(100vh-14rem)] overflow-hidden h-[calc(100vh-14rem)] min-h-full">
<Sidebar className="pt-16" variant="floating">
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
{loaderData.messages.map((message) => (
<MessageRoomCard
key={message.message_room_id}
id={message.message_room_id.toString()}
name={message.name}
lastMessage={message.last_message}
avatarUrl={message.avatar}
/>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<div className=" h-full flex-1">
<Outlet />
</div>
</SidebarProvider>
);
}
> 메시지 보내기
- queries.ts
export const sendMessageToRoom = async (
client: SupabaseClient<Database>,
{
messageRoomId,
message,
userId,
}: { messageRoomId: string; message: string; userId: string }
) => {
const { count, error: countError } = await client
.from("message_room_members")
.select("*", { count: "exact", head: true })
.eq("message_room_id", messageRoomId)
.eq("profile_id", userId);
if (countError) {
throw countError;
}
if (count === 0) {
throw new Error("Message room not found");
}
const { error } = await client.from("messages").insert({
content: message,
message_room_id: Number(messageRoomId),
sender_id: userId,
});
if (error) {
throw error;
}
};
- message-page.tsx
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import type { Route } from "./+types/message-page";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "~/common/components/ui/avatar";
import { Form, useOutletContext } from "react-router";
import { Textarea } from "~/common/components/ui/textarea";
import { Button } from "~/common/components/ui/button";
import { SendIcon } from "lucide-react";
import { MessageBubble } from "../components/message-bubble";
import {
getLoggedInUserId,
getMessagesByMessagesRoomId,
getRoomsParticipant,
sendMessageToRoom,
} from "../queries";
import { makeSSRClient } from "~/supa-client";
import { useEffect, useRef } from "react";
export const meta: Route.MetaFunction = () => {
return [{ title: "Message | wemake" }];
};
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const { client } = await makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const messages = await getMessagesByMessagesRoomId(client, {
messageRoomId: params.messageRoomId,
userId,
});
const participants = await getRoomsParticipant(client, {
messageRoomId: params.messageRoomId,
userId,
});
return {
messages,
participants,
};
};
export const action = async ({ request, params }: Route.ActionArgs) => {
const { client } = await makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const formData = await request.formData();
const message = formData.get("message");
await sendMessageToRoom(client, {
messageRoomId: params.messageRoomId,
message: message as string,
userId,
});
return {
ok: true,
};
};
export default function MessagePage({
loaderData,
actionData,
}: Route.ComponentProps) {
const { userId } = useOutletContext<{ userId: string }>();
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (actionData?.ok) {
formRef.current?.reset();
}
}, [actionData]);
return (
<div className="h-full flex flex-col justify-between">
<Card>
<CardHeader className="flex flex-row items-center gap-4">
<Avatar className="size-14">
<AvatarImage src={loaderData.participants?.profile?.avatar ?? ""} />
<AvatarFallback>
{loaderData.participants?.profile?.name.charAt(0) ?? ""}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0">
<CardTitle className="text-xl">
{loaderData.participants?.profile?.name ?? ""}
</CardTitle>
<CardDescription>2 days ago</CardDescription>
</div>
</CardHeader>
</Card>
<div className="py-10 overflow-y-scroll space-y-4 flex flex-col justify-start h-full">
{loaderData.messages.map((message) => (
<MessageBubble
key={message.message_id}
avatarUrl={message.sender?.avatar ?? ""}
avatarFallback={message.sender?.name.charAt(0) ?? ""}
content={message.content}
isCurrentUser={message.sender?.profile_id === userId}
/>
))}
</div>
<Card>
<CardHeader>
<Form
ref={formRef}
method="post"
className="relative flex justify-end items-center"
>
<Textarea
placeholder="Write a message..."
rows={2}
required
name="message"
className="resize-none"
/>
<Button type="submit" size="icon" className="absolute right-2">
<SendIcon className="size-4" />
</Button>
</Form>
</CardHeader>
</Card>
</div>
);
}
여기까지만 하면, 내가 form에 데이터를 전송할때 화면이 revalidation 되면서 입력한 값이 보여진다. 하지만 상대방은 새로고침 하지 않는한 그대로 이기 때문에, 실시간 전송하는 방법을 아래 이어서 해볼 것이다.
> 메시지 실시간 연동
먼저 supabase에서 세팅을 해야한다. supabase -> database -> publications 에 가면 아래 supabase_realtime 1개만 보일 것이다. (위에꺼는 안보임). 거기에서 table을 누르고 내가 원하는 (messages) 테이블을 on 스위치 해주면 된다.
- message-page.tsx
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "~/common/components/ui/card";
import type { Route } from "./+types/message-page";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "~/common/components/ui/avatar";
import { Form, useOutletContext } from "react-router";
import { Textarea } from "~/common/components/ui/textarea";
import { Button } from "~/common/components/ui/button";
import { SendIcon } from "lucide-react";
import { MessageBubble } from "../components/message-bubble";
import {
getLoggedInUserId,
getMessagesByMessagesRoomId,
getRoomsParticipant,
sendMessageToRoom,
} from "../queries";
import { browserClient, type Database, makeSSRClient } from "~/supa-client";
import { useEffect, useRef, useState } from "react";
export const meta: Route.MetaFunction = () => {
return [{ title: "Message | wemake" }];
};
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const { client } = await makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const messages = await getMessagesByMessagesRoomId(client, {
messageRoomId: params.messageRoomId,
userId,
});
const participant = await getRoomsParticipant(client, {
messageRoomId: params.messageRoomId,
userId,
});
return {
messages,
participant,
};
};
export const action = async ({ request, params }: Route.ActionArgs) => {
const { client } = await makeSSRClient(request);
const userId = await getLoggedInUserId(client);
const formData = await request.formData();
const message = formData.get("message");
await sendMessageToRoom(client, {
messageRoomId: params.messageRoomId,
message: message as string,
userId,
});
return {
ok: true,
};
};
export default function MessagePage({
loaderData,
actionData,
}: Route.ComponentProps) {
const [messages, setMessages] = useState(loaderData.messages);
const { userId, name, avatar } = useOutletContext<{
userId: string;
name: string;
avatar: string;
}>();
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (actionData?.ok) {
formRef.current?.reset();
}
}, [actionData]);
useEffect(() => {
const changes = browserClient
.channel(`room:${userId}-${loaderData.participant?.profile?.profile_id}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "messages",
},
(payload) => {
setMessages((prev) => [
...prev,
payload.new as Database["public"]["Tables"]["messages"]["Row"],
]);
}
)
.subscribe();
return () => {
changes.unsubscribe();
};
}, []);
return (
<div className="h-full flex flex-col justify-between">
<Card>
<CardHeader className="flex flex-row items-center gap-4">
<Avatar className="size-14">
<AvatarImage src={loaderData.participant?.profile?.avatar ?? ""} />
<AvatarFallback>
{loaderData.participant?.profile?.name.charAt(0) ?? ""}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0">
<CardTitle className="text-xl">
{loaderData.participant?.profile?.name ?? ""}
</CardTitle>
<CardDescription>2 days ago</CardDescription>
</div>
</CardHeader>
</Card>
<div className="py-10 overflow-y-scroll space-y-4 flex flex-col justify-start h-full">
{messages.map((message) => (
<MessageBubble
key={message.message_id}
avatarUrl={
message.sender_id === userId
? avatar
: loaderData.participant?.profile?.avatar ?? ""
}
avatarFallback={
message.sender_id === userId
? name.charAt(0)
: loaderData.participant?.profile.name.charAt(0) ?? ""
}
content={message.content}
isCurrentUser={message.sender_id === userId}
/>
))}
</div>
<Card>
<CardHeader>
<Form
ref={formRef}
method="post"
className="relative flex justify-end items-center"
>
<Textarea
placeholder="Write a message..."
rows={2}
required
name="message"
className="resize-none"
/>
<Button type="submit" size="icon" className="absolute right-2">
<SendIcon className="size-4" />
</Button>
</Form>
</CardHeader>
</Card>
</div>
);
}
export const shouldRevalidate = () => false;
1) form을 제출하면 자동으로 revalidate됨. 이를 방지하려면 맨 아래 shouldRevalidate 부분을 false로 처리
2) 브라우저 단에서 subscribe해야 하고, 위 세팅대로 하게 되면, messages 테이블을 모두 공유 하기 때문에 보안에 문제가 생김. (이 문제 해결은 아마 다음 강의에서 다룰 것 같음)
> RLS (Row Level Security) 관리
위 실시간 채팅 구현 같은경우에는 브라우저단에서 접근해야하기 때문에 SUPABASE URL과 ANON_KEY가 노출된다.
그렇기 때문에 누구든지 내 DB에 접근 할 수 있는데, 이를 권한설정으로 막아야 한다.
이를 위해 RLS를 각 테이블별로 설정하도록 하자.
supabase db에서 RLS를 통해 각 테이블별로 접근 권한 설정을 할 수 있다. (만약 설정 안하면 모두 public 된 상태)
각 테이블 별로 아래 버튼이 있고, 해당 버튼을 클릭하고 설정을 진행 하면 된다.
이 때, update 시에는 보통 using과 with check을 동시에 사용 한다.
하지만 INSERT에는 아래와 같은 이유로 using은 필요가 없다.
> Security Definer 관리
security advisor 화면을 보면 errors 탭에 Security Definer View와 RLS~~가 있다. RLS부분은 위에서 다룬 내용이고, Security Definer만 해결하면 된다.
해당 에러는 우리가 sql 구문에서 설정한 View에 권한 설정 때문에 나오는 것이다. 기본적으로 View를 생성할 때 기본 권한은 admin이다 (관리자가 해당 View를 만들었기 때문에 해당 View에 대한 접근 권한은 admin) 이를 해당 View를 호출한 사람 기준으로 바꿔주면 된다.
아래와 같이, with ~~ 구문만 추가해주면 된다.
CREATE OR REPLACE VIEW community_post_list_view
with (security_invoker=on)
AS
SELECT
posts.post_id,
posts.title,
posts.created_at,
topics.name AS topic,
profiles.name AS author,
profiles.avatar AS author_avatar,
profiles.username AS author_username,
posts.upvotes,
topics.slug AS topic_slug,
(SELECT EXISTS (SELECT 1 FROM public.post_upvotes WHERE post_upvotes.post_id = posts.post_id AND post_upvotes.profile_id = auth.uid())) AS is_upvoted
FROM posts
INNER JOIN topics USING (topic_id)
INNER JOIN profiles USING (profile_id);
> Function Search Path
아래와 같이 함수에 대해 search path에러가 뜬다. search path는 어떤 스키마에서 데이터를 찾는지 정해주는 것인데, 기본적으로 아래 코드와 같이 '' 빈 문자열을 넣어준다. 만약 이렇게 빈 문자열을 넣어주게 되면, 테이블 값에 스키마명 (public을 적어주어야한다) 이렇게 되면 해당 에러는 사라진다.
CREATE OR REPLACE FUNCTION public.get_room(from_user_id uuid, to_user_id uuid)
RETURNS TABLE (message_room_id bigint)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
RETURN QUERY SELECT m1.message_room_id
FROM public.message_room_members m1
INNER JOIN public.message_room_members m2
ON m1.message_room_id = m2.message_room_id
WHERE m1.profile_id = from_user_id
AND m2.profile_id = to_user_id;
END;
$$;
**추가로 아래와 같이 함수들에 대해 Security 부분에 Invoker와 Definer가 있는데, 코드에서 내가 해당 함수를 호출할거면 Invoker로 해야 한다(별도 명시를 안하면 Definer로 설정됨). 아래 같은 경우에는 get_room이 Definer로 되어있는 문제가 있다. 실수로 SECURITY DEFINER라고 명시했기 때문이다. 해당 함수를 삭제하고 다시 선언해주면 된다.(이건 연습이니, 따로 안함)
CREATE OR REPLACE FUNCTION public.get_room(from_user_id uuid, to_user_id uuid)
RETURNS TABLE (message_room_id bigint)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
RETURN QUERY SELECT m1.message_room_id
FROM public.message_room_members m1
INNER JOIN public.message_room_members m2
ON m1.message_room_id = m2.message_room_id
WHERE m1.profile_id = from_user_id
AND m2.profile_id = to_user_id;
END;
$$;