Skip to content

Discordクローン作成 11. 会話ページ作成

公開日

表紙

22. Conversationの作成

メンバーをクリックした際に表示される会話ページを作成する。

22-1

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;
 

あまり変化はないが、メンバーから会話ページに遷移するところまで実装。

22-2

内部ではconversationテーブルが作成されている。

23. リアルタイムコネクション

Socket.IO
Socket.IO favicon https://socket.io/

Socket.IO

npmで追加する

サーバサイドとフロントエンド両方必要となるので、socket.iosocket.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.
Routing: API Routes | Next.js favicon https://nextjs.org/docs/pages/building-your-application/routing/api-routes
Routing: API Routes | Next.js

実装はほぼ下記と一緒なので参考に。App Routerでは実装できないというのも同じ

Next.jsでWebSocketアプリケーションを作成する(サーバー編) - Qiita
WebSocketなにかしらのWebアプリケーションを作る際に、ユーザー間でリアルタイムに情報交換が必要な場面があるかと思います。そんな時にはWebSocketというプロトコルを使う事が定番です…
Next.jsでWebSocketアプリケーションを作成する(サーバー編) - Qiita favicon https://qiita.com/ochiochi/items/dbf5040fd665326e8fb5
Next.jsでWebSocketアプリケーションを作成する(サーバー編) - Qiita
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.
Badge favicon https://ui.shadcn.com/docs/components/badge
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)

ここまででポーリングが行われていることが確認できる。

23-1

23-2

チャット入力機能作成

画面下部のチャット入力欄を作成していく。

23-3

チャット入力コンポーネントの作成

  • レイアウト周り以外はこれまでに実装した内容とあまり変わりない。
  • 絵文字、+ボタン機能はまだ未実装。
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>
  );

ここでメッセージ送信できない問題が出てきたので次で解消…