💬

Next.jsとSupabaseで作成したチャットアプリにお知らせ機能を作成する

2024/04/01に公開

※この記事はNOTE等で連載しているNext.js・Supabase開発記事の続きとなります。
以下の内容は下記記事を参照の上、お楽しみください。
https://note.com/libproc/n/n37959203cc65

今回は以前作成したチャットアプリにお知らせ機能を追加したいと思います。
最終系としては添付画像のように

  • 未読お知らせ数がわかる
  • お知らせ一覧が表示される

ようにしたいと思います。

事前準備

こちらの記事の内容をすべて完了させておいてください。
https://note.com/libproc/n/n37959203cc65

この実装をベースに作成を進めます。

Supabase側の設定

既存テーブル修正

ChatsテーブルのisAlreadyRead列をread_atというtimestamptz型の列に変更します。
before

after

お知らせテーブル(notifications)作成

Supabaseダッシュボード→Table EditorNew Tableをクリックし、notificationsテーブルを作成します。
各列は添付画像のような設定で作成します。

ポリシー作成

notificationsテーブルのポリシーを添付画像のように追加します。
権限はSELECTのみで大丈夫です。

お知らせ管理テーブル(notificationmanager)作成

お知らせとユーザを結び付け、どのユーザがどのお知らせを見たかどうかを定義するテーブルです。
各列は添付画像のような設定で作成します。

uidとnotificationidはそれぞれprofilesテーブルとnotificationsテーブルのIDを参照したいため、
外部キーの設定を行います。

設定後のテーブルが添付画像のような見た目です

ポリシー作成

notificationmanagerテーブルのポリシーを添付画像のように追加します。
権限は念のためALLに設定しています。

トリガー作成

お知らせテーブルに新しいお知らせが追加されたら、お知らせ管理テーブルにも全ユーザ分の管理情報を追加するためのトリガーを作成します。

まずは下記のクエリをSQL Editorで実行しましょう。

CREATE OR REPLACE FUNCTION public.insert_notification_manager()
RETURNS TRIGGER AS $$
DECLARE
    user_record RECORD;
BEGIN
    -- 全ユーザーに対してループを実行
    FOR user_record IN SELECT id FROM auth.users LOOP
        -- notificationmanager テーブルに行を挿入
        INSERT INTO notificationmanager (created_at, uid, notificationid, read_at)
        VALUES (CURRENT_TIMESTAMP, user_record.id, NEW.id, NULL);
    END LOOP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

これによりinsert_notification_managerというDatabase Functionが作成されます。
関数の内容としては全ユーザ分ループしてユーザとメッセージを結び付けた行を作成している形です。

次にこの関数を実行するためのトリガーを同じくsqlで作成します。

CREATE TRIGGER trigger_insert_notification_manager
AFTER INSERT ON notifications
FOR EACH ROW
EXECUTE FUNCTION insert_notification_manager();

これによりお知らせが追加されるたびに実行されるようになります。

お知らせを追加すると、

お知らせに対応した管理テーブルの行が全ユーザ分作成される

ここまででSupabase側の対応は終了です。

Next.js側の実装

Supabaseとの連携

新しく作成したテーブルの型を生成するために下記のコマンドを実行しましょう。
まずSupabase CLIにログインします。

npx supabase login

その後型を生成します。
プロジェクトのIDはhttps://supabase.com/dashboard/project/以下の文字列です。

npx supabase gen types typescript --project-id "${プロジェクトのID}" --schema public > types/supabasetype.ts

既存コードの修正

app/chats/page.tsx

チャットアプリの実装にお知らせを追加する、その他read_atの変更を行うなどしています。

"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";
import NotificationList from "@/components/notify/NotificationList";

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 if (items.length > 0) {
      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);
        }

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

  // チャットの更新処理
  const updateChat = async (id: string) => {
    try {
      const timeStamp = new Date().toISOString();
      const index = parseInt(id.split("id")[1]);
      const { error } = await supabase
        .from("Chats")
        .update({ read_at: timeStamp })
        .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, read_at } =
                payload.new;
              const isUser = uid === userID && toID === currentToID;
              const isToUser = uid === currentToID && toID === userID;

              const isAlreadyRead = false;

              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">
      <NotificationList></NotificationList>
      <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>
  );
}

実質的に下記のお知らせリストのコンポーネントを追加しているだけです。

<NotificationList></NotificationList>

お知らせ機能の実装

メインのお知らせ機能の実装を行います。

app/notify/page.tsx

単純にお知らせのテキスト情報を表示し、表示と同時にお知らせ管理テーブルの既読フラグを更新しています。
今後改善するとしたらHTML化、markdown化などが考えられますが、一旦単純な文字列で作成しています。

"use client";
import { useSearchParams } from "next/navigation";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react";
import { Database } from "@/types/supabasetype";
import DateFormatter from "@/components/date";

export default function Notify() {
  const supabase = createClientComponentClient();
  const searchParams = useSearchParams();
  const notificationID = searchParams.get("id");
  const [notifications, setNotifications] = useState<any>({});

  useEffect(() => {
    updateUnreadNotification();
    getNotification();
  }, []);

  const getNotification = async () => {
    const { data: notifications, error } = await supabase
      .from("notifications")
      .select()
      .eq("id", notificationID);

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

    if (notifications.length > 0) {
      setNotifications(notifications[0]);
    }
  };

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

    if (user === null) return;

    const timeStamp = new Date().toISOString();
    await supabase
      .from("notificationmanager")
      .update({ read_at: timeStamp })
      .eq("uid", user.id)
      .eq("notificationid", notificationID);
  };
  return (
    <div className="pt-10 w-2/4 m-auto">
      <p>
        <DateFormatter timestamp={notifications.created_at}></DateFormatter>
      </p>
      <p>{notifications.message}</p>
    </div>
  );
}

URLパラメータに渡したIDから、今回のお知らせを取得しています。

const searchParams = useSearchParams();
const notificationID = searchParams.get("id");

~~~~~~~~~

const { data: notifications, error } = await supabase
      .from("notifications")
      .select()
      .eq("id", notificationID);

既読タイミングをタイムスタンプにしてテーブルを更新しています。

const timeStamp = new Date().toISOString();
await supabase
  .from("notificationmanager")
  .update({ read_at: timeStamp })
  .eq("uid", user.id)
  .eq("notificationid", notificationID);

見た目はシンプルなので特に必要ないですが下記のような形です

components/notify/NotificationList.tsx

チャットアプリ上に表示するお知らせリストのコンポーネントです。
未読のお知らせをカウントし、未読のお知らせは濃い色、既読は薄い色で表示します。

"use client";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react";
import { Database } from "@/types/supabasetype";
import Link from "next/link";

export default function NotificationList() {
  const supabase = createClientComponentClient();
  const [notifications, setNotifications] = useState<any[]>([]);
  const [unreadCount, setUnreadCount] = useState<number>(0);
  const [display, setDisplay] = useState<boolean>(false);

  useEffect(() => {
    getUserData();
  }, []);
  const getUserData = async () => {
    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (user === null) return;
    await getAllNotificationManager(user.id);
  };

  const getAllNotificationManager = async (userID: String) => {
    const { data: notificationManager, error } = await supabase
      .from("notificationmanager")
      .select()
      .eq("uid", userID)
      .order("id", { ascending: false });

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

    const notificationManagerList: Database["public"]["Tables"]["notificationmanager"]["Row"][] =
      notificationManager;

    await getAllNotifications(notificationManagerList);
  };

  const getAllNotifications = async (
    notificationManagerList: Database["public"]["Tables"]["notificationmanager"]["Row"][]
  ) => {
    const { data: notifications, error } = await supabase
      .from("notifications")
      .select();

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

    console.log(notifications);

    const notificationList: Database["public"]["Tables"]["notifications"]["Row"][] =
      notifications;

    // 既読フラグも含めたリスト
    const tmpNotificationList = [];
    let tmpCount = 0;

    for (let i = 0; i < notificationManagerList.length; i++) {
      const manager = notificationManagerList[i];

      for (let j = 0; j < notificationList.length; j++) {
        const notification = notificationList[j];
        if (manager.notificationid === notification.id) {
          let isAlreadyRead = true;
          if (manager.read_at == null) {
            isAlreadyRead = false;
            tmpCount += 1;
          }
          tmpNotificationList.push({
            id: notification.id,
            message: notification.message,
            isAlreadyRead: isAlreadyRead,
          });
        }
      }
    }

    setNotifications(tmpNotificationList);
    setUnreadCount(tmpCount);
  };

  const openList = () => {
    setDisplay(!display);
  };

  return (
    <div className="fixed right-5 top-5">
      <button
        onClick={openList}
        type="button"
        className="relative inline-flex items-center p-3 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
      >
        <svg
          className="w-5 h-5"
          aria-hidden="true"
          xmlns="http://www.w3.org/2000/svg"
          fill="currentColor"
          viewBox="0 0 20 16"
        >
          <path d="m10.036 8.278 9.258-7.79A1.979 1.979 0 0 0 18 0H2A1.987 1.987 0 0 0 .641.541l9.395 7.737Z" />
          <path d="M11.241 9.817c-.36.275-.801.425-1.255.427-.428 0-.845-.138-1.187-.395L0 2.6V14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2.5l-8.759 7.317Z" />
        </svg>
        {unreadCount != 0 ? (
          <>
            <span className="sr-only">Notifications</span>
            <div className="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 -end-2 dark:border-gray-900">
              {unreadCount}
            </div>
          </>
        ) : (
          <></>
        )}
      </button>
      <ul
        className={
          display
            ? "p-3 bg-gray-100 fixed mt-2 right-0 shadow-md overflow-y-auto max-h-60"
            : "p-3 bg-gray-100 fixed mt-2 right-0 shadow-md hidden"
        }
      >
        {notifications.length == 0
          ? "お知らせは存在しません"
          : notifications.map((item, index) => (
              <li className="mt-2 w-60 truncate" key={index}>
                <Link
                  className={
                    item.isAlreadyRead ? " text-blue-400" : " text-blue-700"
                  }
                  href={"/notify?id=" + item.id}
                >
                  {item.message}
                </Link>
              </li>
            ))}
      </ul>
    </div>
  );
}

細かい説明は割愛しますが、お知らせの一覧コンポーネントの作成処理は、

  1. ユーザIDの取得
  2. ユーザIDでフィルタしたお知らせ管理テーブルを取得
  3. お知らせ管理テーブルをもとに未読のお知らせをカウント
  4. 既読フラグを付与したお知らせを配列で取得

という流れで行っています。

実装確認

Supabase、Next.jsの対応ともに終わったのでちゃんと作成できているか確認しましょう。

npm run dev

してアプリにアクセスします。

適当なユーザでログインしましょう。

その後チャット画面に遷移すると右上にお知らせのアイコンが追加されています。

現状はお知らせが一つもないため、
アイコンをクリックしても何も表示されません。

なので、お知らせテーブルにデータを追加してみましょう。
※内容は適当でOKです。

もちろんお知らせ追加と同時にお知らせ管理テーブルにも行が追加されます。

チャット画面をリロードすると右上に未読バッジが3と追加されているのがわかります。

右上のアイコンをクリックすると追加されたお知らせが表示されます。

お知らせをクリックするとお知らせのページにアクセスできます。

アクセス後にチャット画面に戻ると未読が減り、既読のお知らせが薄い色になっていることがわかります。

これでお知らせ機能の作成が確認できました!

その他参考資料など

パブリックリポジトリ
https://github.com/TodoONada/nextjs-supabase-notificationbadge

またTodoONada株式会社では、この記事で紹介した以外にも、各種チャットシステムやストレージを利用した画像アプリの作成方法についてご紹介しています。下記記事にて一覧を見ることができますので、ぜひこちらもご覧ください。
https://note.com/libproc/n/n522396165049

お問合せ:GoogleForm

ホームページ:https://libproc.com

運営会社:TodoONada株式会社

Twitter:https://twitter.com/Todoonada_corp

Instagram:https://www.instagram.com/todoonada_corp/

Youtube:https://www.youtube.com/@todoonada_corp/

Tiktok:https://www.tiktok.com/@todoonada_corp

TodoONada開発ブログ

Discussion