- Discordクローン作成 1. 環境構築〜Clerkの設定
- Discordクローン作成 2. ダークモード〜Prisma, PlanetScale
- Discordクローン作成 3. モーダルUI, 画像(uploadthing)
- Discordクローン作成 4. サーバ作成, ナビゲーション
- Discordクローン作成 5. サーバー作成用モーダル、サイドバー作成
- Discordクローン作成 6. 招待、サーバ設定
- Discordクローン作成 7. メンバーの管理
- Discordクローン作成 8. チャンネル作成、サーバ削除・退出
- Discordクローン作成 9. サーバ検索、チャンネルリスト
- Discordクローン作成 10. チャンネルページ作成
- Discordクローン作成 11. 会話ページ作成 👈ココ
- Discordクローン作成 12. メッセージ送信
- Discordクローン作成 13. リアルタイムチャット
22. Conversationの作成
メンバーをクリックした際に表示される会話ページを作成する。
Conversationライブラリ作成
Convertationテーブルはメンバーの組み合わせを一意にするため、ライブラリを用いて検索・作成を管理する。
lib/conversation.ts
import { db } from "@/lib/db";
export const getOrCreateConversation = async (
memberOneId: string,
memberTwoId: string
) => {
//Conversationは一意とするためOne,Twoどちらでも検索する
let conversation =
(await findConversation(memberOneId, memberTwoId)) ||
(await findConversation(memberTwoId, memberOneId));
if (!conversation) {
conversation = await createConversation(memberOneId, memberTwoId);
}
return conversation;
};
const findConversation = async (memberOneId: string, memberTwoId: string) => {
try {
return await db.conversation.findFirst({
where: {
AND: [{ memberOneId: memberOneId }, { memberTwoId: memberTwoId }],
},
include: {
memberOne: {
include: {
profile: true,
},
},
memberTwo: {
include: {
profile: true,
},
},
},
});
} catch (e) {
return null;
}
};
const createConversation = async (memberOneId: string, memberTwoId: string) => {
try {
return await db.conversation.create({
data: {
memberOneId,
memberTwoId,
},
include: {
memberOne: {
include: {
profile: true,
},
},
memberTwo: {
include: {
profile: true,
},
},
},
});
} catch (e) {
return null;
}
};
ページの修正
app(main)(routes)/servers[serverId]/conversations[memberId]/page.tsx
import { ChatHeader } from "@/components/chat/chat-header";
import { getOrCreateConversation } from "@/lib/conversation";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
interface MemberIdPageProps {
params: {
serverId: string;
memberId: string;
};
}
const MemberIdPage = async ({ params }: MemberIdPageProps) => {
const profile = await currentProfile();
if (!profile) {
return redirectToSignIn();
}
const currentMember = await db.member.findFirst({
where: {
serverId: params.serverId,
profileId: profile.id,
},
include: {
profile: true,
},
});
if (!currentMember) {
return redirect("/");
}
const conversation = await getOrCreateConversation(
currentMember.id,
params.memberId
);
if (!conversation) {
//generalに戻る
return redirect(`/servers/${params.serverId}`);
}
const { memberOne, memberTwo } = conversation;
//会話先のメンバーを特定
const otherMember = memberOne.id === currentMember.id ? memberTwo : memberOne;
return (
<div className="bg-white dark:bg-[#313338] flex flex-col h-full">
<ChatHeader
imageUrl={otherMember.profile.imageUrl}
name={otherMember.profile.name}
serverId={params.serverId}
type="conversation"
/>
</div>
);
};
export default MemberIdPage;
あまり変化はないが、メンバーから会話ページに遷移するところまで実装。
内部ではconversationテーブルが作成されている。
23. リアルタイムコネクション
Socket.IO
Socket.IO
npmで追加する
サーバサイドとフロントエンド両方必要となるので、socket.io
とsocket.io-client
を追加する
terminal
pnpm add socket.io
pnpm add socket.io-client
型定義の追加
それぞれの型については後で調べる。
NextApiResponseServerIo
はAPIのコールバック関数の型定義で、socket.ioの定義を追加している(ここがなぜこの定義で行けると判断できるのかがまだ理解できない)。
types.ts
import { Server as NetServer, Socket } from "net";
import { NextApiResponse } from "next";
import { Server as SocketIOServer } from "socket.io";
import { Member, Profile, Server } from "@prisma/client";
export type ServerWithMembersWithProfiles = Server & {
members: (Member & { profile: Profile })[];
};
export type NextApiResponseServerIo = NextApiResponse & {
socket: Socket & {
server: NetServer & {
io: SocketIOServer;
};
};
};
Socket.IOのページを作成
App Router
を利用しているが、Page Router
側をapiとして動作させることはできる(らしい)。- socket.ioのapiを用意して、各画面から接続できるように準備を行う。
Page Routerの説明は以下
Routing: API Routes | Next.js
Next.js supports API Routes, which allow you to build your API without leaving your Next.js app. Learn how it works here.
実装はほぼ下記と一緒なので参考に。App Routerでは実装できない
というのも同じ
Next.jsでWebSocketアプリケーションを作成する(サーバー編) - Qiita
WebSocketなにかしらのWebアプリケーションを作る際に、ユーザー間でリアルタイムに情報交換が必要な場面があるかと思います。そんな時にはWebSocketというプロトコルを使う事が定番です…

pages/api/socket/io.ts
import { Server as NetServer } from "http";
import { NextApiRequest } from "next";
import { Server as SocketIO } from "socket.io";
import { NextApiResponseServerIo } from "@/types";
export const config = {
api: {
bodyParser: false,
},
};
const ioHandler = (req: NextApiRequest, res: NextApiResponseServerIo) => {
if (!res.socket.server.io) {
const path = "/api/socket/io";
const httpServer: NetServer = res.socket.server as any;
const io = new SocketIO(httpServer, {
path: path,
addTrailingSlash: false,
});
res.socket.server.io = io;
}
res.end();
};
export default ioHandler;
Socket.IOのプロバイダを作成する
- useContextを使ったちゃんとしたプロバイダを作成(やってることは読めるけど全然理解してないので後から理解する)
components/providers/socket-provider.tsx
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { io as ClientIO } from "socket.io-client";
type SocketContextType = {
socket: any | null;
isConnected: boolean;
};
const SocketContext = createContext<SocketContextType>({
socket: null,
isConnected: false,
});
export const useSocket = () => {
return useContext(SocketContext);
};
export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
const [socket, setSocket] = useState(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socketInstance = new (ClientIO as any)(
process.env.NEXT_PUBLIC_SITE_URL!,
{
path: "/api/socket/io",
addTrailingSlash: false,
}
);
socketInstance.on("connect", () => {
setIsConnected(true);
});
socketInstance.on("disconnect", () => {
setIsConnected(false);
});
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, []);
return (
<SocketContext.Provider value={{ socket, isConnected }}>
{children}
</SocketContext.Provider>
);
};
ルートレイアウトにプロバイダを追加する
app/layout.tsx
import { SocketProvider } from "@/components/providers/socket-provider";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClerkProvider>
<html lang="ja" suppressHydrationWarning>
<body className={cn(font.className, "bg-white dark:bg-[#313338]")}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableColorScheme={false}
storageKey="discord-theme"
>
<SocketProvider>
<ModalProvider />
{children}
</SocketProvider>
</ThemeProvider>
</body>
</html>
</ClerkProvider>
);
}
接続のインジケーターを追加する
インジケータはbadgeを用いる
Badge
Displays a badge or a component that looks like a badge.

tetminal
pnpm dlx shadcn-ui@latest add badge
components/socket-indicator.tsx
import { useSocket } from "@/components/providers/socket-provider";
import { Badge } from "@/components/ui/badge";
export const SocketIndicator = () => {
const { isConnected } = useSocket();
if (!isConnected) {
return (
<Badge variant="outline" className="bg-yellow-600 text-white border-none">
Fallback: Polling every 1s
</Badge>
);
}
return (
<Badge variant="outline" className="bg-emerald-600 text-white border-none">
Live:Real-time updates
</Badge>
);
};
[Tips]見えてるはずのものでエラーになる
- どうも見えないエラーが発生してしまって困った。
- 理由は単純で
use client
の付け忘れ。 use cilent
が必要な場合は大体わかるエラーが出ていたため、なんじゃこりゃとなった。useXXXX
でエラーのときは無条件でuse client
を疑った方が良いかも。
⨯ components/socket-indicator.tsx (5:35) @ useSocket
⨯ TypeError: (0 , _components_providers_socket_provider__WEBPACK_IMPORTED ⨯ TypeEr ⨯ TypeError: (0 , _components_providers_socket_provider__WEBPACK_IMP ⨯ TypeError: (0 , _components_providers_socket_provider__WEBPACK_IMPORTED_MODULE_2__.useSocket) is not a function
at SocketIndicator (./components/socket-indicator.tsx:13:109)
at stringify (<anonymous>)
digest: "3290327044"
3 |
4 | export const SocketIndicator = () => {
> 5 | const { isConnected } = useSocket();
| ^
6 |
7 | if (!isConnected) {
8 | return (
✓ Compiled /favicon.ico in 270ms (949 modules)
ここまででポーリングが行われていることが確認できる。
チャット入力機能作成
画面下部のチャット入力欄を作成していく。
チャット入力コンポーネントの作成
- レイアウト周り以外はこれまでに実装した内容とあまり変わりない。
- 絵文字、+ボタン機能はまだ未実装。
components/chat/chat-input.tsx
"use client";
import { useForm } from "react-hook-form";
import * as z from "zod";
import axios from "axios";
import qs from "query-string";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Plus, Smile } from "lucide-react";
interface ChatInputProps {
apiUrl: string;
query: Record<string, any>;
name: string;
type: "channel" | "conversation";
}
const formSchema = z.object({
content: z.string().min(1),
});
export const ChatInput = ({ apiUrl, query, name, type }: ChatInputProps) => {
const form = useForm<z.infer<typeof formSchema>>({
defaultValues: {
content: "",
},
});
const isLoading = form.formState.isSubmitting;
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
const url = qs.stringifyUrl({
url: apiUrl,
query: query,
});
await axios.post(url, values);
form.reset();
} catch (error) {
console.error(error);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="relative p-4 pb-6">
<button
type="button"
className=" absolute top-7 left-8 h-[24px] w-[24px] bg-zinc-500 dark:bg-zinc-400
hover:bg-zinc-600 dark:hover:bg-zinc-300 transition rounded-full p-1
flex items-center justify-center"
onClick={() => {}}
>
<Plus className=" text-white dark:text-[#313338]" />
</button>
<Input
disabled={isLoading}
className="px-14 py-6 bg-zinc-200/90 dark:bg-zinc-700/75 border-none border-0
focus-visible:ring-0 focus-visible:ring-offset-0 text-zinc-600 dark:text-zinc-200"
placeholder={`Message ${
type === "conversation" ? name : "#" + name
}`}
{...field}
/>
<div className="absolute top-7 right-8">
<Smile />
</div>
</div>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
);
};
app(main)(routes)/servers[serverId]/channels[channelId]/page.tsx
return (
<div className="bg-white dark:bg-[#313338] flex flex-col h-full">
<ChatHeader
serverId={params.serverId}
name={channel.name}
type="channel"
/>
<div className="flex-1">Feature Messages</div>
<ChatInput
name={channel.name}
type="channel"
apiUrl="/api/socket/messages"
query={{
channelId: channel.id,
serverId: channel.serverId,
}}
/>
</div>
);
ここでメッセージ送信できない問題が出てきたので次で解消…