Skip to content

Discordクローン作成 6. 招待、サーバ設定

公開日

表紙

12. 招待機能

  • 招待リンクを生成し、そこからメンバーをサーバに招待できるように実装していく。
  • モーダルの状態はまとめてストアで管理(zustand)。これにより多重起動を防ぐ。

モーダルストアの修正

ストアにdataを追加

モーダルストアのフックにdataを追加し、サーバ情報をモーダルに渡せるようにする。

  • data でサーバー情報を受け取れる形に修正を行う。
  • createServer では使わないのでオプショナルとする。
  • onOpendataにもセットする
hooks/use-modal-store.ts
import { Server } from "@prisma/client";
import { create } from "zustand";
 
export type ModalType = "createServer" | "invite";
 
interface ModalData {
  server?: Server;
}
 
interface ModalStore {
  type: ModalType | null;
  data: ModalData;
  isOpen: boolean;
  onOpen: (type: ModalType, data?: ModalData) => void;
  onClose: () => void;
}
 
export const useModal = create<ModalStore>((set) => ({
  type: null,
  data: {},
  isOpen: false,
  onOpen: (type, data = {}) => set({ isOpen: true, type: type, data }),
  onClose: () => set({ isOpen: false, type: null }),
}));
 
 

サーバヘッダー部にモーダルを開く機能追加

  • 「ユーザーを招待する」からモーダルが開くように修正
components/server/server-header.tsx
export const ServerHeader = ({ server, role }: ServerHeaderProps) => {
  const { onOpen } = useModal();
 
  const isAdmin = role === MemberRole.ADMIN;
  const isModerator = isAdmin || role === MemberRole.MODERATOR;
 
  return (
    <DropdownMenu>
      <DropdownMenuTrigger className="focus:outline-none" asChild>
        <button className="w-full text-md font-semibold px-3 flex items-center h-12 border-neu dark:border-neutral-800 border-b-2 hover:bg-zinc-700/10 dark:hover:bg-zinc-700/50 transition">
          {server.name}
          <ChevronDown className="h-5 w-5 ml-auto" />
        </button>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56 text-xs font-medium text-black dark:text-neutral-400 space-y-[2px]">
        {isModerator && (
          <DropdownMenuItem
            onClick={() => onOpen("invite", { server: server })}
            className="text-indigo-600 dark:text-indigo-400 px-3 py-2 text-sm cursor-pointer"
          >
            ユーザーを招待する
            <UserPlus className="h-4 w-4 ml-auto" />
          </DropdownMenuItem>
        )}

招待用モーダル

招待用モーダルコンポーネントの作成

  • create-server-modalをコピーしてinvite-modalを作成する。
  • isModalOpeninviteを指定する。
components/modals/invite-modal.tsx
"use client";
 
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
 
import { useModal } from "@/hooks/use-modal-store";
 
export const InviteModal = () => {
  const { isOpen, onClose, type } = useModal();
 
  const isModalOpen = isOpen && type === "invite";
 
  return (
    <Dialog open={isModalOpen} onOpenChange={onClose}>
      <DialogContent className="bg-white text-black p-0 overflow-hidden">
        <DialogHeader className="pt-8 px-6">
          <DialogTitle className="text-2xl text-center font-bold">
            ユーザーを招待する
          </DialogTitle>
          <DialogDescription className="text-center text-zinc-500">
            他のユーザーをこのサーバーに招待します。
          </DialogDescription>
        </DialogHeader>
        ユーザーを招待する
      </DialogContent>
    </Dialog>
  );
};

招待用モーダルをモーダルプロバイダに追加

  • 「モーダル」など機能ごとのコンポーネントはこのように一元管理すると良さげ。
components/providers/modal-provider.tsx
"use client";
 
import { CreateServerModal } from "@/components/modals/create-server-modal";
import { InviteModal } from "@/components/modals/invite-modal";
import { useEffect, useState } from "react";
 
export const ModalProvider = () => {
  const [isOpen, setIsOpen] = useState(false);
 
  useEffect(() => {
    setIsOpen(true);
  }, []);
 
  if (!isOpen) {
    return null;
  }
 
  return (
    <>
      <CreateServerModal />
      <InviteModal />
    </>
  );
};
 

モーダルの起動を確認しておく 12-1

招待モーダルの機能を実装

モーダル表示までできたので機能を作成していく。

  • 招待用リンクのinputフォームを作成する
  • 新しい招待用リンクの生成ボタンを作成する
components/modals/invite-modal.tsx
"use client";
 
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
 
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useModal } from "@/hooks/use-modal-store";
import { Copy, RefreshCcw } from "lucide-react";
 
export const InviteModal = () => {
  const { isOpen, onClose, type } = useModal();
 
  const isModalOpen = isOpen && type === "invite";
 
  return (
    <Dialog open={isModalOpen} onOpenChange={onClose}>
      <DialogContent className="bg-white text-black p-0 overflow-hidden">
        <DialogHeader className="pt-8 px-6">
          <DialogTitle className="text-2xl text-center font-bold">
            友人を招待する
          </DialogTitle>
        </DialogHeader>
        <div className="p-6">
          <Label className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70">
            サーバー招待用リンク
          </Label>
          <div className="flex items-center mt-2 gap-x-2">
            <Input
              className="bg-zinc-300/50 border-0 focus-visible:ring-0 text-black focus-visible:ring-offset-0"
              value="招待用リンク"
            />
            <Button size="icon">
              <Copy className="w-4 h-4" />
            </Button>
          </div>
          <Button
            variant={"link"}
            size="sm"
            className="text-xs text-zinc-500 mt-4"
          >
            新しい招待用リンクを生成する
            <RefreshCcw className="w-4 h-4 ml-2" />
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
};

12-2

windowオブジェクトのフックを作成

  • サーバURLを取得するためwindowオブジェクトを利用する
  • クライアント側で動くので、右に倣えでマウントを待って(=クライアントサイドで)実行するようにフックを作成する。

window.locationは以下情報を持つ

window.location
{
    "ancestorOrigins": {},
    "href": "http://localhost:3000/servers/ecde1899-a955-448d-8539-d152cbfafa1c",
    "origin": "http://localhost:3000",
    "protocol": "http:",
    "host": "localhost:3000",
    "hostname": "localhost",
    "port": "3000",
    "pathname": "/servers/ecde1899-a955-448d-8539-d152cbfafa1c",
    "search": "",
    "hash": ""
}

フックを作成。 useXXXはクライアントでしか動かないので、サーバサイドレンダリング時には動かない。

hooks/use-origin.ts
import { useEffect, useState } from "react";
 
export const useOrigin = () => {
  const [mounted, setMounted] = useState(false);
 
  useEffect(() => {
    setMounted(true);
  }, []);
 
  const origin = typeof window !== "undefined" ? window.location.origin : "";
 
  if (!mounted) {
    return "";
  }
 
  return origin;
};
 

招待用リンクの実装

inviteURLは以下のようになる。

http://localhost:3000/invite/179cc4c8-1ffb-4b3f-aa30-211be2f49845

origin + server.inviteCode でこれを実装していく。

components/modals/invite-modal.tsx
export const InviteModal = () => {
  const { isOpen, onClose, type, data } = useModal();
  const origin = useOrigin();
 
  const isModalOpen = isOpen && type === "invite";
 
  const server = data?.server;
  const inviteUrl = `${origin}/invite/${server?.inviteCode}`;
 

コピーボタン機能実装

  • コピーボタン押下でonCopyを実行
  • onCopyでクリップボードにinviteUrlをコピーする
  • コピー完了後に1秒後にコピー完了を解除する。これにより「コピー実行」が視覚的にわかるように制御する。
components/modals/invite-modal.tsx
const onCopy = () => {
  navigator.clipboard.writeText(inviteUrl);
  setCopied(true);
  setTimeout(() => {
    setCopied(false);
  }, 1000);
};
/**~~~~~~~~~~**/
<Button onClick={onCopy} size="icon">
  {copied ? (
    <Check className="w-4 h-4" />
  ) : (
    <Copy className="w-4 h-4" />
  )}
</Button>
 
 

招待リンク生成処理の実装

  • APIでサーバ情報が持つ招待リンクを更新する
  • 更新後、招待モーダルの onOpenを再度実行することで開いているモーダルを破棄し、生成後のURLを持つモーダルを再生成する。
  • 実行中はisLoadingをtrueにしてイベント実行を抑止する(ボタンなどをdisabledにする)
components/modals/invite-modal.tsx
  const onNew = async () => {
    try {
      setIsLoading(true);
      const response = await axios.patch(
        `/api/servers/${server?.id}/invite-code`
      );
      //生成し直した招待用リンクを開く
      onOpen("invite", { server: response.data });
    } catch (error) {
      console.error(error);
    } finally {
      setIsLoading(false);
    }
  };

最終的には以下のようになる。

components/modals/invite-modal.tsx
"use client";
 
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
 
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useModal } from "@/hooks/use-modal-store";
import { useOrigin } from "@/hooks/use-origin";
import axios from "axios";
import { Check, Copy, RefreshCcw } from "lucide-react";
import { useState } from "react";
 
export const InviteModal = () => {
  const { onOpen, isOpen, onClose, type, data } = useModal();
  const [copied, setCopied] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const origin = useOrigin();
 
  const isModalOpen = isOpen && type === "invite";
 
  const server = data?.server;
  const inviteUrl = `${origin}/invite/${server?.inviteCode}`;
 
  const onCopy = () => {
    navigator.clipboard.writeText(inviteUrl);
    setCopied(true);
    setTimeout(() => {
      setCopied(false);
    }, 1000);
  };
 
  const onNew = async () => {
    try {
      setIsLoading(true);
      const response = await axios.patch(
        `/api/servers/${server?.id}/invite-code`
      );
      //生成し直した招待用リンクを開く
      onOpen("invite", { server: response.data });
    } catch (error) {
      console.error(error);
    } finally {
      setIsLoading(false);
    }
  };
  return (
    <Dialog open={isModalOpen} onOpenChange={onClose}>
      <DialogContent className="bg-white text-black p-0 overflow-hidden">
        <DialogHeader className="pt-8 px-6">
          <DialogTitle className="text-2xl text-center font-bold">
            友人を招待する
          </DialogTitle>
        </DialogHeader>
        <div className="p-6">
          <Label className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70">
            サーバー招待用リンク
          </Label>
          <div className="flex items-center mt-2 gap-x-2">
            <Input
              disabled={isLoading}
              className="bg-zinc-300/50 border-0 focus-visible:ring-0 text-black focus-visible:ring-offset-0"
              value={inviteUrl}
            />
            <Button disabled={isLoading} onClick={onCopy} size="icon">
              {copied ? (
                <Check className="w-4 h-4" />
              ) : (
                <Copy className="w-4 h-4" />
              )}
            </Button>
          </div>
          <Button
            onClick={onNew}
            disabled={isLoading}
            variant={"link"}
            size="sm"
            className="text-xs text-zinc-500 mt-4"
          >
            新しい招待用リンクを生成する
            <RefreshCcw className="w-4 h-4 ml-2" />
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
};
 

招待リンク生成API

  • サーバはユーザ(Profile)に紐づく形のため、更新できるのは作成したユーザのみ。ログインユーザ情報を使うことでこれを実現する。
  • サーバ側から見てMemberを作成する(ここはPrismaのORMらしい使い方)
  • Memberはゲストとして作成される。
app/api/servers/[serverId]/invite-code/route.ts
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
 
interface InviteCodePageProps {
  params: {
    inviteCode: string;
  };
}
 
const InviteCodePage = async ({ params }: InviteCodePageProps) => {
  const profile = await currentProfile();
 
  if (!profile) {
    return redirectToSignIn();
  }
 
  if (!params.inviteCode) {
    return redirect("/");
  }
 
  //サーバに所属しているか確認
  const existingServer = await db.server.findFirst({
    where: {
      inviteCode: params.inviteCode,
      members: {
        some: {
          profileId: profile.id,
        },
      },
    },
  });
  if (existingServer) {
    return redirect(`/servers/${existingServer.id}`);
  }
 
  //サーバから見てMemberのUpdate(create)を実行する
  const server = await db.server.update({
    where: {
      inviteCode: params.inviteCode,
    },
    data: {
      members: {
        create: {
          profileId: profile.id,
        },
      },
    },
  });
 
  if (server) {
    return redirect(`/servers/${server.id}`);
  }
 
  return null;
};
 
export default InviteCodePage;

12-3

招待用リンクからユーザを招待

DBの誤り修正

  • ServerのinviteCodeをuniqueに更新。
  • Serverを軸として所属メンバーの更新をかける際、ユニークキーである必要があるため。
prisma/schema.prisma
model Server {
  id String @id @default(uuid())
  name String
  imageUrl String @db.Text
  inviteCode String @unique
 
  profileId String
  profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
 
  members Member[]
  channels Channel[]
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  @@index ([profileId])
}
 

DBの差し替えを行う。。 更新に失敗する場合は pnpx prisma migrate reset にてデータを一度消してから更新する(特に支障がないなら消してからやった方が無難)。

terminal
pnpx prisma migrate reset
 
pnpx prisma generate
pnpx prisma db push

これで招待用リンクからユーザを招待できるようになった。 (ユーザ自体が未登録だと場合だと遷移時にエラーが出るけど、たぶん軽微なバグ)

12-4

13.サーバ設定機能

サーバ設定モーダルの作成

サーバ設定モーダルを作成していく。

モーダルストアに editServer を追加

hooks/use-modal-store.ts
export type ModalType = "createServer" | "invite" | "editServer";

コンポーネントの追加

  • create-server-modal.tsxをコピーしてedit-server-modal.tsx を作成。
  • ひとまず名前とType変えるだけ
components/modals/edit-server-modal.tsx
export const EditServerModal = () => {
  const { isOpen, onClose, type } = useModal();
  const router = useRouter();
 
  const isModalOpen = isOpen && type === "editServer";
 
/** 省略 **/
};

モーダルプロバイダに追加

components/providers/modal-provider.tsx
"use client";
 
import { CreateServerModal } from "@/components/modals/create-server-modal";
import { EditServerModal } from "@/components/modals/edit-server-modal";
import { InviteModal } from "@/components/modals/invite-modal";
import { useEffect, useState } from "react";
 
export const ModalProvider = () => {
  const [isOpen, setIsOpen] = useState(false);
 
  useEffect(() => {
    setIsOpen(true);
  }, []);
 
  if (!isOpen) {
    return null;
  }
 
  return (
    <>
      <CreateServerModal />
      <InviteModal />
      <EditServerModal />
    </>
  );
};

サーバヘッダーに設定ボタンを追加

components/server/server-header.tsx
<DropdownMenuItem
  onClick={() => onOpen("editServer", { server: server })}
  className="px-3 py-2 text-sm cursor-pointer"
>
  サーバー設定
  <Settings className="h-4 w-4 ml-auto" />
</DropdownMenuItem>

サーバ設定モーダルに初期値を与える

  • useEffectでDBのデータをreact-hook-formの初期値としてセットする。
  • こうすることでモーダル側に選択したサーバ設定が反映される
components/modals/edit-server-modal.tsx
//初期値を設定
useEffect(() => {
  if (server) {
    // form.setValue("name", server.name);
    // form.setValue("imageUrl", server.imageUrl);
    form.reset({
      name: server.name,
      imageUrl: server.imageUrl,
    });
  }
}, [server, form]);

変更ボタンの実装

  • submot時の apiをpostからpatchに変更する
  • serverのidを特定できるようにする
components/modals/edit-server-modal.tsx
  const onSubmit = async (values: z.infer<typeof formSchema>) => {
    try {
      await axios.patch(`/api/servers/${server?.id}`, values);
 
      //初期化
      form.reset();
      router.refresh();
      onClose();
    } catch (error) {
      console.error(error);
    }
  };

APIの作成

  • サーバの更新を行うAPIを作成する
  • 内容はcreateとほとんど変わらず。
app/api/servers/[serverId]/route.ts
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
 
export async function PATCH(
  req: Request,
  { params }: { params: { serverId: string } }
) {
  try {
    const profile = await currentProfile();
    const { name, imageUrl } = await req.json();
 
    if (!profile) {
      return new NextResponse("Unauthorized", { status: 401 });
    }
 
    const server = await db.server.update({
      where: {
        id: params.serverId,
        profileId: profile.id,
      },
      data: {
        name,
        imageUrl,
      },
    });
 
    return NextResponse.json(server);
  } catch (error) {
    console.error("[SERVERS_ID_PATCH]", error);
    return new NextResponse("Internal Server Error", { status: 500 });
  }
}