Next.jsとSupabaseで認証つきチャットアプリを作成する(SNS風UI)

先日はNext.jsとSupabaseを使って『認証つきリアルタイムチャット』を作るところまで行きました。

今回は更に使い勝手のいい『SNS風UIのついた認証付きチャットアプリ』を作りましょう。
具体的には下記のような画面です。

ここまでできれば、もうアプリに実装してもいいレベルですね。
一見難しく見えるかもしれませんが、手順を追っていけば大丈夫です。
では作り方を見てみましょう。

目次

Supabase側の設定

①:Supabaseプロジェクトの作成

Supabaseにログイン(登録していなければアカウント登録から)して、
トップページの「New Project」を押します。

すると、下記の様な画面が表示されます。

適当なプロジェクト名とデータベースのパスワードを入れて、新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。

②:ユーザ作成

`Authentication`を開き、ユーザを5人作ります。
メールアドレスとパスワードが必要ですが、メールアドレスはテスト用として`example.com`のものを利用します。
『example.com』は例示用に確保されているセカンドレベルドメインなので、うっかりそのまま実装しても事故が起きにくいです。

右上の`Add User`→`Create New User`を押すと、下記の画面が出て来ます。


任意のパスワードで下記のメールアドレスのユーザを作ってください

※ユーザ作成時`Auto Confirm User?`のチェックは外さないでください。

下記のように追加ができていればOKです

③:テーブル作成

今回のチャットアプリでは、『ユーザプロフィール(シンプルに名前のみ)定義用のテーブル』と、『チャット情報のテーブル』が必要になりますのでそれぞれ作成しましょう。

ユーザプロフィールテーブル

まずユーザプロフィールです。
SupabaseのDashboardの`Table Editor`メニューから`New Table`ボタンを押します。
すると新規テーブルの設定画面が出るので、添付画像のように設定してください。

ポイントはID横のリンクボタンで、こちらをクリックすると下記の画面が出てきます。外部キーを参照する設定が出来ます。
今回はIDを①で作成したユーザに結びつけたいので、下記の様な設定にしてください。


※”Action if referenced row is removed”にCascadeという設定をしていますが、参照先のキーが削除されたらデータを削除する設定です。
つまり、ユーザが削除されたらこのテーブル上のデータも同様に削除されます。

ユーザプロフィールデータ作成

今回はアプリ側でのプロフィールデータ入力画面等は作成しないため、事前にプロフィールデータを入れておきましょう。
添付画像の様に`Insert Row`から作成したユーザの名前を指定してください。

チャット情報テーブル

チャット情報テーブルは下記の情報を元に作成します。

  • メッセージのid
  • 作成日時
  • メッセージの内容
  • ユーザのid
  • 送る相手のid
  • 既読フラグ

ユーザプロフィールと同じようにSupabaseのDashboardの`Table Editor`メニューから`New Table`ボタンを押します。
すると新規テーブルの設定画面が出るので、添付画像のように設定してください。

こちらはリアルタイム機能を使いたいため、「Enable Realtime」の有効化チェックを入れましょう。

また、ユーザプロフィールと同じく外部キーの参照を行っています。
uidの右のリンクボタンをクリックし、添付画像の様な設定にしましょう。


toIDも同じように上の画像の設定で参照を行います。

チャット情報テーブルの作成も完了です。
アクセス制限のためのポリシーは後ほど作成します。

Next.js事前準備

①:認証機能のリポジトリをクローン

今回はこちらの記事で紹介している認証機能(Supabase Auth)を元に実装を進めたいので、
まずはこちらの記事内にあるgithubのリンク から`git clone`してリポジトリを持ってきてください。

②:起動確認

Supabase Authが正しく動くかをまず確認しましょう。

クローンしたプロジェクトの直下に`.env.local`を新規作成し、
内容を下記の通り入力します

# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL={ここにProject URLを入れる}
NEXT_PUBLIC_SUPABASE_ANON_KEY={ここにProject API Anon Keyを入れる}

それぞれ『ここに◯◯を入れる』と書かれている部分に
Supabaseのダッシュボードの`Project Settings`の`API`から必要な情報をコピーします。

コピーが終わったら、

npm install

を実行した上で

npm run dev

を実行します。

下記の画面が表示されるか確認してください。

設定したメールアドレスでLoginを行い、Profileページが表示されたらOKです。

SupabaseのDBとの連携

DBの型生成

Next.jsで実装するためにまずはDatabaseの型を生成しましょう。
まずはプロジェクトのディレクトリ直下に`types`フォルダを作成します。(ファイルは生成されるため作成せずでOKです。)

下記で、Supabase CLI上でログインします。
(対話的に進めていけばログイン出来ます。)

npx supabase login

その後、Databaseの型生成用のコマンドを実行します。
($PROJECT_IDは、SupabaseのDashboardでプロジェクトを選択した際のURLの`https://supabase.com/dashboard/project/`以下の文字列です。)

npx supabase gen types typescript --project-id "$PROJECT_ID" --schema public > types/supabasetype.ts

これを実行すると、`supabasetype.ts`のファイルの中身が生成されていることが確認出来ます。

utils/supabase/supabase.ts

Databaseにアクセスするため、事前に`supabase.ts`の中身を作成します。
先程生成した`supabasetype.ts`もここで利用しています。

import { createClient } from '@supabase/supabase-js'
import { Database } from '@/types/supabasetype'

export const supabase = createClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);

.env.localに入力したURLとAPIキーがここで利用されます。

その他の実装

主要部分ではないレイアウトなどの実装をまずは行います。
※存在しないファイルはそれぞれ記述のファイルパスで作成してください。

app/page.tsx

トップページのレイアウトの作成を行います

export default async function Index() {

  return (
    <div className="flex-1 w-full flex flex-col items-center">
      <h1 className="text-3xl font-bold pt-6 pb-10">認証機能ありリアルタイムチャットアプリ</h1>
    </div>
  )
}

components/date.tsx

タイムスタンプ型の日付を見やすい形にして返すコンポーネントです。

type Props = {
  timestamp: string
}

export default function DateFormatter({ timestamp }: Props) {
  const date = new Date(timestamp)
  var jstDate = date.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" })

  return (
    <>
      {jstDate}
    </>
  )
}

主要部分の実装

app/profile/page.tsx

元々あるプロフィールページにユーザ名を変更できる機能を作成します。


"use client"
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react";

/**
 * ログイン後のマイページ
 */
const MyPage = () => {
  const supabase = createClientComponentClient();
  const [name, setName] = useState("");
  const [userID, setUserID] = useState("");

  useEffect(() => {
    getData();
  }, []);

  const getData = async () => {
    const { data: { user } } = await supabase.auth.getUser();

    if (user === null) return

    setUserID(user.id)


    const { data: profile, error } = await supabase
      .from('profiles')
      .select()
      .eq("id", user.id)

    if (error) {
      console.log(error);
      return
    }

    if (profile.length === 1) {
      setName(profile[0].name)
    }

  }

  const onChangeName = async (event: any) => {
    event.preventDefault();
    if (userID === "") {
      return
    }
    const { data, error } = await supabase
      .from('profiles')
      .upsert({ id: userID, name: name })
      .select()
    if (error) {
      console.log(error);
      return
    }
  }



  return (
    <div className="mx-auto max-w-xl px-4 sm:px-6 lg:px-8 pb-16 pt-20 text-center lg:pt-32">
      <h1 className="text-2xl font-bold">
        ログインに成功しました
      </h1>
      <div className="pt-10">
        <form onSubmit={onChangeName}>
          <label
            htmlFor="name"
            className="block mb-2 text-sm text-left font-medium text-gray-900"
          >
            名前
          </label>
          <div className="flex w-full">
            <input
              type="text"
              name="name"
              id="name"
              className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
              placeholder="山田 太郎"
              onChange={(e) => setName(e.target.value)}
              value={name}
              required
            />
            <button
              className="ml-2 min-w-fit text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
              type="submit"
            >
              更新
            </button>
          </div>
        </form>
      </div>
      <div className="pt-10">
        <form action="/auth/logout" method="post">
          <button
            className=" text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
            type="submit"
          >
            ログアウト
          </button>
        </form>
      </div>
    </div>
  )
}


export default MyPage;

今回、ユーザ名が既に存在している場合は更新、存在していない場合は追加するため`upsert`を利用しています。

const { data, error } = await supabase
      .from('profiles')
      .upsert({ id: userID, name: name })
      .select()

これにより更新にも追加にも対応できるようになるため、ユーザ名等の変更も可能です。

こちらの画面は下記のような見た目になります。

app/chats/page.tsx

チャットアプリのページになります。
mutationObserver、IntersectionObserver等を利用して結構複雑な実装になっているため後ほど説明します。

"use client"
import SideBar from '@/components/chats/sideBar'
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { Database } from "@/types/supabasetype"
import { useEffect, useRef, useState } from "react"
import ChatUI from '@/components/chats/chatUI';

export default function Chats() {
  const supabase = createClientComponentClient()
  const [userID, setUserID] = useState("")
  const [currentToID, setCurrentToID] = useState("")
  const [profiles, setProfiles] = useState<Database["public"]["Tables"]["profiles"]["Row"][]>([])
  const [inputText, setInputText] = useState("")
  const [messageText, setMessageText] = useState<any[]>([])
  const [isScrolled, setIsScrolled] = useState(false)
  const scrollElement = useRef(null)
  const [intersectionObserver, setIntersectionObserver] = useState<IntersectionObserver>()
  const [mutationObserver, setMutationObserver] = useState<MutationObserver>()

  // 一個目の未読メッセージまでスクロールする
  const scrollToFirstUnread = () => {
    setIsScrolled(true)
    const items = document.querySelectorAll('[data-isalreadyread]');


    const firstUnreadItem = Array.from(items).find(item => item.getAttribute("data-isalreadyread") === 'false');

    if (firstUnreadItem) {
      firstUnreadItem.scrollIntoView({ behavior: 'smooth', block: 'start' });
    } else {
      items[items.length - 1].scrollIntoView({ behavior: 'smooth', block: 'start' });
    }
  };

  // 未読メッセージが画面に入った時のイベント
  const intersectionObserverCallback = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
    entries.forEach(async entry => {
      if (entry.isIntersecting) {
        if (entry.target.getAttribute("data-isalreadyread") !== "true" && !entry.target.classList.contains("isMyMessage")) {
          entry.target.setAttribute("data-isalreadyread", "true");
          await updateChat(entry.target.id, true)
        }

        observer.unobserve(entry.target);
      }
    });
  };

  // チャットの更新処理
  const updateChat = async (id: string, value: boolean) => {
    try {
      const index = parseInt(id.split("id")[1])
      const { error } = await supabase
        .from('Chats')
        .update({ isAlreadyRead: value })
        .eq("id", index);
      if (error) {
        console.error(error)
        return
      }
    } catch (error) {
      console.error(error)
      return
    }
  }

  useEffect(() => {
    getUserID()


    const tmpIntersectionObserver = new IntersectionObserver(intersectionObserverCallback, {
      root: null,
      rootMargin: '0px',
      threshold: 0.1
    });
    setIntersectionObserver(tmpIntersectionObserver)

    const tmpMutationObserver = new MutationObserver(mutations => {
      tmpMutationObserver.disconnect()
      mutations.forEach(mutation => {
        const element: HTMLElement = scrollElement.current!
        element.scrollTop = element.scrollHeight
      });
    });
    setMutationObserver(tmpMutationObserver)
  }, [])

  const addToRefs = (el: never) => {
    if (el) {
      // 要素監視のためにintersectionObserverに追加
      intersectionObserver!.observe(el);
      if (!isScrolled) {
        scrollToFirstUnread()
      }
    }
  };

  const getUserID = async () => {
    const { data: { user } } = await supabase.auth.getUser()
    if (user != null) {
      setUserID(user.id)
    }
  }

  const fetchAndMergeChats = async (toID: string) => {
    // 1つ目の配列を取得
    const { data: chats1, error: error1 } = await supabase
      .from('Chats')
      .select('*')
      .eq('uid', userID)
      .eq('toID', toID)
      .order('created_at');

    // 2つ目の配列を取得
    const { data: chats2, error: error2 } = await supabase
      .from('Chats')
      .select('*')
      .eq('uid', toID)
      .eq('toID', userID)
      .order('created_at');

    if (error1 || error2) {
      console.error('Error fetching chats', error1 || error2);
      return;
    }
    const fixed_chats1 = []
    for (let index = 0; index < chats1.length; index++) {
      fixed_chats1.push({ "created_at": chats1[index].created_at, "id": chats1[index].id, "message": chats1[index].message, "toID": chats1[index].toID, "uid": chats1[index].uid, "isAlreadyRead": chats1[index].isAlreadyRead, "isMyMessage": true })
    }

    const fixed_chats2 = []
    for (let index = 0; index < chats2.length; index++) {
      fixed_chats2.push({ "created_at": chats2[index].created_at, "id": chats2[index].id, "message": chats2[index].message, "toID": chats2[index].toID, "uid": chats2[index].uid, "isAlreadyRead": chats2[index].isAlreadyRead, "isMyMessage": false })
    }

    // 配列をマージ
    const mergedChats = [...fixed_chats1, ...fixed_chats2];

    // `created_at`でソート
    mergedChats.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());

    return mergedChats;
  }

  const getMessages = async (toID: string) => {
    let allMessages = null
    try {
      const data = await fetchAndMergeChats(toID)

      allMessages = data
    } catch (error) {
      console.error(error)
    }
    if (allMessages != null) {
      setMessageText(allMessages)
    }
  }

  const fetchRealtimeData = (currentToID: string) => {
    try {
      supabase
        .channel("chats")
        .on(
          "postgres_changes",
          {
            event: "*",
            schema: "public",
            table: "Chats",
          },
          (payload) => {
            // insert時の処理
            if (payload.eventType === "INSERT") {
              const { created_at, id, message, toID, uid, isAlreadyRead } = payload.new
              const isUser = uid === userID && toID === currentToID
              const isToUser = uid === currentToID && toID === userID

              if (isUser || isToUser) {
                let isMyMessage = true;
                if (uid === currentToID) {
                  isMyMessage = false;
                }
                setMessageText((messageText) => [...messageText, { created_at, id, message, toID, uid, isAlreadyRead, isMyMessage }])
              }
            }
            // update時に既読マークをつける。
            if (payload.eventType === "UPDATE") {
              const { id, toID, uid } = payload.new
              const element = document.querySelector(`#id${id} .isAlreadyRead`)
              if (element && uid === userID && toID === currentToID) {
                element.textContent = "既読"
              }
            }
          }
        )
        .subscribe()

    } catch (error) {
      console.error(error)
    }
  }

  const handleSelectUser = async (event: any) => {
    event.preventDefault()
    setIsScrolled(false);
    const toUserID = event.target.id;

    setCurrentToID(toUserID)

    await getMessages(toUserID)

    fetchRealtimeData(toUserID)
  }

  const onSubmitNewMessage = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    if (inputText === "") return
    try {
      await supabase.from("Chats").insert({ message: inputText, uid: userID, toID: currentToID })
    } catch (error) {
      console.error(error)
      return
    }
    setInputText("")

    mutationObserver!.observe(scrollElement.current!, { childList: true })
  }

  return (
    <div className="mt-10 container mx-auto shadow-lg rounded-lg">
      <div className="flex flex-row justify-between bg-white">
        <SideBar profiles={profiles} setProfiles={setProfiles} handleClick={handleSelectUser}></SideBar>
        <div className="w-full px-5 flex flex-col justify-between">
          <div className="flex flex-col mt-5">
            <div ref={scrollElement} id='scrollElement' className='overflow-y-scroll h-96'>
              {messageText.map((item, index) => (
                <div key={index} ref={addToRefs} className={item.isMyMessage ? "flex mb-4 justify-start flex-row-reverse isMyMessage" : "flex justify-start mb-4"} id={"id" + item.id} data-isalreadyread={!item.isMyMessage ? item.isAlreadyRead : ""}>
                  <ChatUI item={item}></ChatUI>
                </div>
              ))}
            </div>
            <div className="py-5">
              <form className="w-full flex" onSubmit={onSubmitNewMessage}>
                <input
                  className="w-full bg-gray-300 py-5 px-3 rounded-xl"
                  type="text"
                  id="message"
                  name="message"
                  placeholder="type your message here..."
                  value={inputText}
                  onChange={(event) => setInputText(() => event.target.value)}
                />
                <button type="submit" disabled={inputText === ""} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-20 ml-2 px-5 py-2.5 text-center disabled:opacity-25">
                  送信
                </button>
              </form>
            </div>

          </div>
        </div>
      </div>
    </div >
  )
}

詳しくポイントを見ていきましょう。

fetchAndMergeChats

まずは表示時にチャット一覧を取得する処理になります。
今回、サイドバーにあるユーザ情報をクリックするとそのユーザとの一対一のチャットが行える画面が表示されるため、
自分のチャットと相手のチャットを分けて管理する必要があります。

const fetchAndMergeChats = async (toID: string) => {
    // 1つ目の配列を取得
    const { data: chats1, error: error1 } = await supabase
      .from('Chats')
      .select('*')
      .eq('uid', userID)
      .eq('toID', toID)
      .order('created_at');

    // 2つ目の配列を取得
    const { data: chats2, error: error2 } = await supabase
      .from('Chats')
      .select('*')
      .eq('uid', toID)
      .eq('toID', userID)
      .order('created_at');

    if (error1 || error2) {
      console.error('Error fetching chats', error1 || error2);
      return;
    }
    const fixed_chats1 = []
    for (let index = 0; index < chats1.length; index++) {
      fixed_chats1.push({ "created_at": chats1[index].created_at, "id": chats1[index].id, "message": chats1[index].message, "toID": chats1[index].toID, "uid": chats1[index].uid, "isAlreadyRead": chats1[index].isAlreadyRead, "isMyMessage": true })
    }

    const fixed_chats2 = []
    for (let index = 0; index < chats2.length; index++) {
      fixed_chats2.push({ "created_at": chats2[index].created_at, "id": chats2[index].id, "message": chats2[index].message, "toID": chats2[index].toID, "uid": chats2[index].uid, "isAlreadyRead": chats2[index].isAlreadyRead, "isMyMessage": false })
    }

    // 配列をマージ
    const mergedChats = [...fixed_chats1, ...fixed_chats2];

    // `created_at`でソート
    mergedChats.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());

    return mergedChats;
  }

fetchRealtimeData

リアルタイム機能を利用し、`Chats`テーブルの変更イベントを受け取ってアプリに反映します。
`INSERT`イベントの際にメッセージ一覧をリロードすること無く新たなメッセージの追加のみ行っています。
また、`UPDATE`イベントは既読済みかどうかの更新時に行われるため既読ラベルの出し分けを行っています。

const fetchRealtimeData = (currentToID: string) => {
    try {
      supabase
        .channel("chats")
        .on(
          "postgres_changes",
          {
            event: "*",
            schema: "public",
            table: "Chats",
          },
          (payload) => {
            // insert時の処理
            if (payload.eventType === "INSERT") {
              const { created_at, id, message, toID, uid, isAlreadyRead } = payload.new
              const isUser = uid === userID && toID === currentToID
              const isToUser = uid === currentToID && toID === userID

              if (isUser || isToUser) {
                let isMyMessage = true;
                if (uid === currentToID) {
                  isMyMessage = false;
                }
                setMessageText((messageText) => [...messageText, { created_at, id, message, toID, uid, isAlreadyRead, isMyMessage }])
              }
            }
            // update時に既読マークをつける。
            if (payload.eventType === "UPDATE") {
              const { id, toID, uid } = payload.new
              const element = document.querySelector(`#id${id} .isAlreadyRead`)
              if (element && uid === userID && toID === currentToID) {
                element.textContent = "既読"
              }
            }
          }
        )
        .subscribe()

    } catch (error) {
      console.error(error)
    }
  }

IntersectionObserver周り

今回未読メッセージが画面に入ったら既読フラグを付けるという処理を行うためにIntersectionObserverを利用しています。
Reactでは下記のようにobserver自体を状態管理する必要があります。

  const [intersectionObserver, setIntersectionObserver] = useState<IntersectionObserver>()

下記の様にcallbackを設定しつつuseStateにsetしています。

const tmpIntersectionObserver = new IntersectionObserver(intersectionObserverCallback, {
      root: null,
      rootMargin: '0px',
      threshold: 0.1
    });
setIntersectionObserver(tmpIntersectionObserver)

const intersectionObserverCallback = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
    entries.forEach(async entry => {
      if (entry.isIntersecting) {
        if (entry.target.getAttribute("data-isalreadyread") !== "true" && !entry.target.classList.contains("isMyMessage")) {
          entry.target.setAttribute("data-isalreadyread", "true");
          await updateChat(entry.target.id, true)
        }

        observer.unobserve(entry.target);
      }
    });
  };    

mutationObserver周り

mutationObserverはメッセージが追加された際の自動スクロールに利用しています。

下記のようにスクロール可能な要素の親を参照し、

  const scrollElement = useRef(null)

下記で要素内に新たなメッセージが追加されたら、スクロールをするように設定しています。

const tmpMutationObserver = new MutationObserver(mutations => {
      tmpMutationObserver.disconnect()
      mutations.forEach(mutation => {
        const element: HTMLElement = scrollElement.current!
        element.scrollTop = element.scrollHeight
      });
    });
setMutationObserver(tmpMutationObserver)

その他細かい処理を行うための関数を作成しているため、類似のチャットアプリを作成する際に参考にしてください。

components/chats/chatUI.tsx

投稿されたチャットを表示するUIです。
メッセージを相手と自分で左右に振り分ける、既読フラグの出し分けをするなどの工夫を行っています。

import DateFormatter from '@/components/date';

interface Props {
  item: any,
}

export default function ChatUI(props: Props) {
  const { item } = props

  return (
    <>
      <img
        src="/user.png"
        className="object-cover h-8 w-8 rounded-full"
        alt=""
      />
      <div>
        <div
          className={item.isMyMessage ? "mr-2 py-3 px-4 bg-blue-400 rounded-bl-3xl rounded-tl-3xl rounded-tr-xl text-white" : "ml-2 py-3 px-4 bg-gray-400 rounded-br-3xl rounded-tr-3xl rounded-tl-xl text-white"}
        >
          {item.message}
        </div>
        <p className='text-gray-500 font-normal text-xs truncate text-right w-full'>
          <DateFormatter timestamp={item.created_at} />
          <span className='isAlreadyRead'>{item.isMyMessage && item.isAlreadyRead ? "既読" : ""}</span>
        </p>
      </div>
    </>
  )
}

メッセージのクラス指定で`isMyMessage`というフラグを使って自分のメッセージかどうかを判別しています。

className={item.isMyMessage ? "mr-2 py-3 px-4 bg-blue-400 rounded-bl-3xl rounded-tl-3xl rounded-tr-xl text-white" : "ml-2 py-3 px-4 bg-gray-400 rounded-br-3xl rounded-tr-3xl rounded-tl-xl text-white"}

既読のメッセージかどうかは下記で変更しています。
相手のメッセージに既読と表示する必要はないため、自分のメッセージに絞って表示しています。

<span className='isAlreadyRead'>{item.isMyMessage && item.isAlreadyRead ? "既読" : ""}</span>

components/chats/sideBar.tsx

こちらのサイドバー部分のUIの実装を行います。

"use client"
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import Image from 'next/image'
import { Database } from "@/types/supabasetype"
import { useEffect, useState, Dispatch, SetStateAction } from "react"

interface Props {
  profiles: Database["public"]["Tables"]["profiles"]["Row"][]
  setProfiles: Dispatch<SetStateAction<Database["public"]["Tables"]["profiles"]["Row"][]>>,
  handleClick: Function
}

export default function SideBar(
  props: Props
) {
  const { profiles, setProfiles, handleClick } = props
  const supabase = createClientComponentClient()

  const [selectedId, setSelectedId] = useState("");

  const getData = async () => {
    try {
      const { data: { user } } = await supabase.auth.getUser()
      if (user == null) {
        return
      }

      const { data: profile, error } = await supabase
        .from('profiles')
        .select()
        .neq("id", user.id)

      if (error) {
        console.log(error)
        return
      }
      const profileList: Database["public"]["Tables"]["profiles"]["Row"][] = profile

      setProfiles(profileList)

    } catch (error) {
      console.error(error)
      return
    }
  }

  useEffect(() => {
    getData()
  }, [])

  const handleStyle = (id: string) => {
    setSelectedId(id)
  }

  return (
    <div className="flex flex-col w-2/5 border-r-2 overflow-y-auto">
      {profiles.map((item, index) => (
        <div
          id={item.id}
          className="flex flex-row py-4 px-2 justify-center items-center border-b-2"
          style={
            { backgroundColor: selectedId === item.id ? "#eee " : "#fff" }
          }
          onClick={(e) => { handleClick(e); handleStyle(item.id); }}
          key={index}
        >
          <div className="w-1/4 min-w-max pointer-events-none">
            <Image src="/user.png" className="object-cover h-auto w-12 rounded-full" width="30" height="30" alt=""></Image>
          </div>
          <div className="w-3/4 pointer-events-none">
            <div className="text-lg font-semibold">
              {item.name}
              <span className=" block text-gray-500 font-normal text-xs truncate">ID: {item.id}</span>
            </div>
          </div>
        </div>
      ))}
    </div>
  )
}

選択したユーザ名のみ背景を変えるため、IDを状態管理しています。

  const [selectedId, setSelectedId] = useState("");

ユーザ情報全体のdivに下記のような記述を行い、見た目のハンドリングを行っています。

style={
 { backgroundColor: selectedId === item.id ? "#eee " : "#fff" }
}
onClick={(e) => { handleClick(e); handleStyle(item.id); }}

ここまでで実装は完了ですが、`npm run dev`する前にポリシーの設定をしてチャット情報、プロフィールに適切にアクセスできるようにしましょう。

テーブルのポリシー設定

このチャットアプリは、認証したユーザのみがチャットのやり取りができるようにしたいため、

チャットにユーザ名を表示するために使うプロフィール

  • 認証ユーザが見ることができる
  • 同じIDのユーザしか更新、追加出来ない

とし、

チャット情報

  • 認証ユーザが見る&投稿ができる

という設定にします。

まずはユーザプロフィールのポリシーを設定しましょう

ユーザプロフィールテーブルのポリシー設定

`Table Editor`の`profiles`の右のボタンをクリックし、`View Policies`を押しましょう。

表示された画面で`New policy`ボタンを押します。
`For Full Customization`をクリックし、それぞれ下記の設定でポリシーを作ってください。

`Select`の設定

『認証ユーザ』のみ見ることができる状態にします。

`Insert`の設定

追加は『認証ユーザ』かつ『ユーザIDがログインしたユーザと同じ(自分)』人しか出来ないようにします。

`Update`の設定

更新も『認証ユーザ』かつ『ユーザIDがログインしたユーザと同じ(自分)』しか出来ないようにします。

チャット情報テーブルのポリシー設定

同じように`Chats`でユーザのポリシーにアクセスします。

表示された画面で`New policy`ボタンを押します。
`For Full Customization`をクリックし、それぞれ下記の設定でポリシーを作ってください。

`Select`の設定

プロフィールと同じく『認証ユーザ』のみアクセス可能にします。

`Insert`の設定

『認証ユーザ』のみが投稿できるようにします。

`Update`の設定

『認証ユーザ』のみが更新できるようにします。

これでポリシーの設定は完了です。

アプリの確認

実際に挙動を確認してみます。

npm run dev

上記を入力してlocalhostにアクセスすると、下記のような画面が現れます。

まずは`Login`からuser1@example.comでログインしてみましょう。
ログインすると下記のような画面が現れます。

ログインは終わったので他のユーザとチャットしてみます。
`Chats`をクリックすると、自分(ユーザ1)以外のユーザがメニューに表示されます。

ユーザ2をクリックして「こんにちはユーザ2」と投稿してみます。
すると、入力した内容が画面上に自分からのメッセージとして表示されます。

続いて10個ほどメッセージを入力してみましょう。

この状態で別ウインドウでユーザ2にログインして、ユーザ1とのチャット画面を表示してみます。


先程入力したユーザ1のメッセージが、相手からのメッセージとして表示されます。

ここでユーザ1のチャットに戻ってみると、上から5つのメッセージが既読になったことがわかります。

それ以下のメッセージは既読がついていないです

これでユーザ2に戻り一番下までスクロールすると、連動してユーザ1側のすべてのメッセージに既読がつくことがわかります。

次はユーザ2側でメッセージを入力してみましょう。

ユーザ1側で確認すると相手からのメッセージが表示されていることがわかります。

これで認証ユーザ同士のチャットアプリの基本機能が作成出来ていることが確認できました。
気になる方は更に別のユーザ同士でやりとりをしたり、別ウインドウで開いてリアルタイム性を体感するなど試してみましょう!

その他参考資料など

今回のコードは下記githubにて公開しております!

またTodoONada株式会社では、チャットシステム以外にも、ストレージを利用した画像アプリやTodoアプリ等の作成方法についてご紹介しています。
ぜひこちらもご覧ください!

お問合せ&各種リンク

presented by

  • システム開発、アプリ開発
  • マッチングアプリ開発
  • インフラ構築支援等、なんでもご相談ください。
よかったらシェアしてね!
  • URLをコピーしました!
目次