今回はメッセージのボタン類に置くメッセージ数や未読メッセージの通知機能と、それを可能にするための未読、既読の更新機能を作成します。
通知機能はほんの小さな表示ではありますが、あるとないとでは使い勝手や、ユーザーの利便性が大きく異なります。
また、実装は簡単そうに見えるかもしれませんが、未読数のカウント・表示、そして既読時の処理等、そこそこ工数がかかる箇所です。
では早速作り方をみてみましょう。
Supabase側の設定
trn_notification(通知テーブル)の作成
ユーザーごとに通知数を管理するためのテーブルを作成します。
添付画像のような設定で作成してください。
すべてnot nullで作成し、
user_uidは`auth`のユーザIDを外部参照するようにしてください。
Database Function作成
続いて、メッセージがテーブルに追加されるたびに通知テーブルの通知数を更新する仕組みをDatabase Functionとして作成します。
`SQL Editor`を開き新しいSQLを作成し、下記を入力してください。
CREATE OR REPLACE FUNCTION update_notification_count()
RETURNS TRIGGER AS $$
BEGIN
-- 未読メッセージの数をカウント
UPDATE trn_notification
SET badge_count = (
SELECT COUNT(*)
FROM trn_apply_message
WHERE receiver_id = NEW.receiver_id AND read_at IS NULL AND delete_flg = FALSE
),
updated_at = NOW()
WHERE user_uid = NEW.receiver_id;
-- 通知数が存在しない場合は新しく挿入
INSERT INTO trn_notification (user_uid, badge_count, created_at, updated_at)
SELECT NEW.receiver_id, COUNT(*), NOW(), NOW()
FROM trn_apply_message
WHERE receiver_id = NEW.receiver_id AND read_at IS NULL AND delete_flg = FALSE
ON CONFLICT (user_uid)
DO UPDATE SET
badge_count = EXCLUDED.badge_count,
updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
トリガー作成
続いて、上記の関数をメッセージ追加のたびに実行するためのトリガーを作成します。
同じようにSQLでトリガーを作成します。
CREATE TRIGGER after_message_insert
AFTER INSERT ON trn_apply_message
FOR EACH ROW
EXECUTE FUNCTION update_notification_count();
メッセージ更新時にも同様に作成します。
CREATE TRIGGER after_message_insert
AFTER INSERT ON trn_apply_message
FOR EACH ROW
EXECUTE FUNCTION update_notification_count();
TypeScriptの型生成
Next.jsのプロジェクト直下でSupabaseにログインします。
npx supabase login
その後、下記を実行して今回のテーブルの追加を反映しましょう。
npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts
こちらでSupabase側の設定は完了です。
Next.jsの実装
まずはヘッダー周辺から対応します。
components/Header.tsx
メッセージのリンクに通知数を表示するため、丸々コンポーネント化しています。
import {createClient} from "@/utils/supabase/server";
import Link from "next/link";
import {redirect} from "next/navigation";
import {getUserTypeFromEnv, userType} from "@/utils/usertype";
import HeaderSearchForm from "@/components/jobseeker/HeaderSearchForm";
import Logo from "@/components/Logo";
import MessageLink from "@/components/header/MessageLink";
export default async function Header() {
const supabase = createClient();
const usertype = getUserTypeFromEnv();
let userDataFlg = false;
const {
data: {user},
} = await supabase.auth.getUser();
const isUserDataExist = async () => {
if (usertype == userType.company) {
const {data, error } = await supabase.from("mst_company").select().eq("user_uid", user?.id)
if (data?.length ? data?.length : 0 > 0) {
userDataFlg = true
}
} else if (usertype == userType.job_seeker) {
const {data, error } = await supabase.from("mst_job_seeker").select().eq("user_uid", user?.id)
if (data?.length ? data?.length : 0 > 0) {
userDataFlg = true
}
}
}
await isUserDataExist()
const signOut = async () => {
"use server";
const supabase = createClient();
await supabase.auth.signOut();
return redirect("/login");
};
return (
<nav className="w-full p-3 text-sm border-b border-gray-300 bg-white">
<Logo></Logo>
{user && usertype == userType.company ? (
<div className="flex justify-end items-center gap-4">
{userDataFlg ? (<>
<Link className="text-center w-20" href="/myjoblist">
<div className="block">
<span className="material-symbols-outlined">
work
</span>
</div>
自社の求人
</Link>
<Link className="text-center w-20" href="/jobseekerlist">
<div className="block">
<span className="material-symbols-outlined">
badge
</span>
</div>
求職者一覧
</Link>
<MessageLink user={user} />
</>) : null}
<Link className="text-center w-20" href="/userpage">
<div className="block">
<span className="material-symbols-outlined">
person
</span>
</div>
ユーザ
</Link>
<form className="content-center" action={signOut}>
<button
className="w-24 py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover">
ログアウト
</button>
</form>
</div>
) : user && usertype == userType.job_seeker ? (
<div className="w-full flex justify-between gap-4">
<HeaderSearchForm></HeaderSearchForm>
<div className="flex">
{userDataFlg ? (<>
<Link className="text-center w-20" href="/joblist">
<div className="block">
<span className="material-symbols-outlined">
work
</span>
</div>
求人一覧
</Link>
<Link className="text-center w-20" href="/companylist">
<div className="block">
<span className="material-symbols-outlined">
apartment
</span>
</div>
企業一覧
</Link>
<MessageLink user={user} />
</>) : null}
<Link className="text-center w-20" href="/userpage">
<div className="block">
<span className="material-symbols-outlined">
person
</span>
</div>
ユーザ
</Link>
<form className="content-center" action={signOut}>
<button
className="w-24 py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover">
ログアウト
</button>
</form>
</div>
</div>
) : (
<div className="flex justify-end items-center gap-4">
<Link
href="/login"
className="w-24 py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover"
>
ログイン
</Link>
<Link
href="/signup"
className="w-24 py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover"
>
会員登録
</Link>
</div>
)}
</nav>
);
}
components/header/MessageLink.tsx
メッセージのリンク部分です。
"use client"
import Link from "next/link";
import {User} from "@supabase/gotrue-js";
import {useEffect, useState} from "react";
import {createClient} from "@/utils/supabase/client";
type Props = {
user: User | null;
}
export default function MessageLink({user}: Props) {
const [count, setCount] = useState(0);
const supabase = createClient();
useEffect(() => {
getCount()
}, []);
const getCount = async () => {
if (user == null) return;
const {data, error} = await supabase.from("trn_notification").select().eq("user_uid", user.id)
if (error) return
if (data != null && data.length > 0) {
setCount(data[0]["badge_count"]);
}
}
return (
<Link className="text-center w-20" href="/chats">
<div className="block">
<span className="material-symbols-outlined">
chat
</span>
{count > 0 && (
<span className="inline-block absolute bg-red-600 text-xs w-4 h-4 text-white rounded-full">
{count > 99 ? ("99+") : (count)}
</span>
)}
</div>
メッセージ
</Link>)
}
components/chats/ChatView.tsx
通知を表示するための赤丸を表示する対応などを行っています。
"use client"
import {useEffect, useState} from "react";
import {createClient} from "@/utils/supabase/client";
import {getUserTypeFromEnv, userType} from "@/utils/usertype";
import {Database} from "@/types/supabase";
import ChatList from "@/components/chats/ChatList";
import {User} from "@supabase/gotrue-js";
import {getYearsOld} from "@/utils/jobseekerUtils";
type Props = {
usertype: userType
}
export default function ChatView({usertype}: Props) {
const supabase = createClient();
const [jobSeekerJobs, setJobSeekerJobs] = useState<any[]>([]);
const [currentJobSeekerJobID, setCurrentJobSeekerJobID] = useState<number | null>(null);
const [currentSender, setCurrentSender] = useState<User | null>(null);
useEffect(() => {
getJobSeekerJob()
}, []);
// ユーザと結びつくjob_seeker_job IDを検索
const getJobSeekerJob = async () => {
let tmpUser: User | null
if (currentSender) {
tmpUser = currentSender
} else {
const {data: {user}} = await supabase.auth.getUser()
setCurrentSender(user)
tmpUser = user
}
const usertype = getUserTypeFromEnv()
let fixedJobList: Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][] = []
let result: any[] = []
if (usertype == userType.job_seeker && tmpUser != null) {
const {data, error} = await supabase.from("m2m_job_seeker_job").select().eq("job_seeker_id", tmpUser.id)
if (error) return;
fixedJobList = data as Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][]
} else if (usertype == userType.company && tmpUser != null) {
const jobList = await getJobListFromCompanyID(tmpUser.id)
for (let i = 0; i < jobList.length; i++) {
const {data, error} = await supabase.from("m2m_job_seeker_job").select().eq("job_id", jobList[i]["id"])
if (error) continue;
fixedJobList = fixedJobList.concat(data as Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][]);
}
}
// 求人名、求職者名取得
for (let i = 0; i < fixedJobList.length; i++) {
const jobList = await getJobListFromJobID(fixedJobList[i]["job_id"])
if (jobList.length == 0) continue
const {
data,
error
} = await supabase.from("mst_job_seeker").select().eq("user_uid", fixedJobList[i]["job_seeker_id"])
if (error || data == null || data.length == 0) continue
const tmp_data = data as Database["public"]["Tables"]["mst_job_seeker"]["Row"][]
const yearsOld = getYearsOld(tmp_data[0]["birthday"])
let statusStr = ""
switch (fixedJobList[i]["status"]) {
case "selection_resume":
statusStr = "書類選考中"
break;
case "scheduling_inteview":
statusStr = "面接日程調整中"
break;
case "waiting_inteview_result":
statusStr = "面接結果待ち"
break;
case "reject":
statusStr = "不採用"
break;
case "offer":
statusStr = "オファー"
break
case "decline_before_offer":
statusStr = "内定前辞退"
break
case "decline_after_offer":
statusStr = "内定後辞退"
break
case "accept_offer":
statusStr = "内定承諾"
break
}
const count = await getJobNotifyCount(tmpUser, fixedJobList[i]["id"])
result.push({
id: fixedJobList[i]["id"],
jobName: jobList[0]["name"],
username: tmp_data[0]["last_name"] + " " + tmp_data[0]["first_name"],
yearsOld: yearsOld,
status: statusStr,
count: count
})
}
setJobSeekerJobs(result)
}
const getJobNotifyCount = async (currentUser: User | null, job_seeker_job_id: number) => {
if (currentUser == null) return 0
let count = 0
const {
data,
error
} = await supabase.from("trn_apply_message").select().eq("job_seeker_job_id", job_seeker_job_id)
if (error) return 0
if (data != null && data.length > 0) {
for (let i = 0; i < data.length; i++) {
if (data[i].sender_id != currentUser?.id && data[i].read_at == null) {
count++
}
}
}
return count
}
const getJobListFromJobID = async (id: string) => {
const {data, error} = await supabase.from("trn_job").select().eq("id", id)
if (error) return []
return data as Database["public"]["Tables"]["trn_job"]["Row"][]
}
const getJobListFromCompanyID = async (companyId: string) => {
const {data, error} = await supabase.from("trn_job").select().eq("company_uid", companyId)
if (error) return []
return data as Database["public"]["Tables"]["trn_job"]["Row"][]
}
const [showStatusModal, setShowStatusModal] = useState<boolean>(false)
const [currentStatus, setCurrentStatus] = useState<string>("")
const handleStatus = async (id: string) => {
const result = window.confirm("この求職者のステータスを変更しますか?");
if (!result) return
setShowStatusModal(false)
const {error} = await supabase.from("m2m_job_seeker_job").update({status: currentStatus}).eq("id", id)
if (error) return
await getJobSeekerJob()
}
return (<div className="relative flex w-full max-w-4xl h-max min-h-screen pb-16">
<ul className="w-3/5 bg-white">
{jobSeekerJobs.map((item) => (
<li className="relative border-b-gray-200 border-b" key={item.id}>
<button onClick={() => {
setCurrentJobSeekerJobID(item.id)
}}
className="relative text-left p-4 bg-white w-full hover:bg-gray-200 flex items-center justify-between">
<div className="w-48 font-bold text-lg">{item.jobName}<br/><span
className="font-normal text-base">{item.username} {item.yearsOld}歳</span></div>
{usertype == userType.job_seeker ? (
<div
className="relative text-sm px-1 bg-gray-300 rounded-md text-center">
{item.status}
</div>) : usertype == userType.company ? (
<button onClick={() => setShowStatusModal(true)}
className="relative text-sm px-1 bg-gray-300 rounded-md text-center hover:bg-gray-50">
{item.status}
</button>) : null}
{item.count > 0 && (
<span
className="bg-red-600 w-3 h-3 rounded-full text-white text-xs absolute top-3 right-3 text-center"></span>
)}
</button>
{showStatusModal ? (
<div className="absolute top-0 right-0 p-4 z-50 border border-gray-300 bg-gray-100">
<div className="text-right">
<button onClick={() => setShowStatusModal(false)}>
<span className="material-symbols-outlined">
close
</span>
</button>
</div>
<p>ステータスを選択してください。</p>
<select className="border block mb-4"
onChange={(e) => setCurrentStatus(e.target.value)}
>
<option value="selection_resume" selected={item.status === "書類選考中"}>書類選考中
</option>
<option value="scheduling_inteview"
selected={item.status === "面接日程調整中"}>面接日程調整中
</option>
<option value="waiting_inteview_result"
selected={item.status === "面接結果待ち"}>面接結果待ち
</option>
<option value="reject" selected={item.status === "不採用"}>不採用</option>
<option value="offer" selected={item.status === "オファー"}>オファー</option>
<option value="decline_before_offer"
selected={item.status === "内定前辞退"}>内定前辞退
</option>
<option value="decline_after_offer"
selected={item.status === "内定後辞退"}>内定後辞退
</option>
<option value="accept_offer"
selected={item.status === "内定承諾"}>内定承諾
</option>
</select>
<div className="w-full text-right">
<button className="bg-green-500 hover:bg-green-600 rounded-md px-4 py-2 text-white"
onClick={() => handleStatus(item.id)}>保存
</button>
</div>
</div>) : null}
</li>
))}
</ul>
<div className="w-full bg-gray-200 border-l p-4">
<ChatList user={currentSender} job_seeker_jobid={currentJobSeekerJobID}></ChatList>
</div>
</div>)
}
components/chats/ChatList.tsx
下記の記事の既読管理部分を再利用して、既読の管理をしています。
https://note.com/libproc/n/nd50cc66cb314
"use client"
import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";
import {User} from "@supabase/gotrue-js";
import {Database} from "@/types/supabase";
import SendButton from "@/components/chats/SendButton";
type Props = {
user: User | null,
job_seeker_jobid: number | null
}
export default function ChatList({user, job_seeker_jobid}: Props) {
const supabase = createClient()
const [chatData, setChatData] = useState<Database["public"]["Tables"]["trn_apply_message"]["Row"][]>([]);
// リロードのためにトグルするやつ。
const [reloadFlg, setReloadFlg] = useState<boolean>(false);
const [isScrolled, setIsScrolled] = useState(false)
const [intersectionObserver, setIntersectionObserver] = useState<IntersectionObserver>()
useEffect(() => {
if (job_seeker_jobid == null || user == null) return
getChatData()
setReloadFlg(false)
const tmpIntersectionObserver = new IntersectionObserver(intersectionObserverCallback, {
root: null,
rootMargin: '0px',
threshold: 0.1
});
setIntersectionObserver(tmpIntersectionObserver)
}, [job_seeker_jobid, reloadFlg]);
// 一個目の未読メッセージまでスクロールする
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 addToRefs = (el: never) => {
if (el) {
// 要素監視のためにintersectionObserverに追加
intersectionObserver!.observe(el);
if (!isScrolled) {
scrollToFirstUnread()
}
}
};
// チャットの更新処理
const updateChat = async (id: string) => {
try {
const timeStamp = new Date().toISOString();
const index = parseInt(id.split("id")[1]);
const {error} = await supabase
.from("trn_apply_message")
.update({read_at: timeStamp})
.eq("id", index);
if (error) {
console.error(error);
return;
}
} catch (error) {
console.error(error);
return;
}
}
const getChatData = async () => {
const {
data,
error
} = await supabase.from("trn_apply_message").select().eq("job_seeker_job_id", job_seeker_jobid)
if (error) {
console.log(error);
return []
}
setChatData(data)
return data as Database["public"]["Tables"]["trn_apply_message"]["Row"][]
}
return (<div>
<ul>
{chatData.map((item) => (
<li ref={addToRefs}
className={user?.id == item.sender_id ? ("flex justify-end mb-2 isMyMessage") : ("flex mb-2")}
key={item.id}
data-isalreadyread={user?.id != item.sender_id && item.read_at == null ? "false" : "true"}
id={"id" + item.id}
>
<div
className={user?.id == item.sender_id ? ("ml-4") : ("mr-4")}
>
<div
className={user?.id == item.sender_id ? ("inline-block rounded-md p-2 bg-green-500 text-white") : ("inline-block rounded-md p-2 bg-white")}>
{item.message}
</div>
{user?.id == item.sender_id && item.read_at != null && (
<div className="text-gray-500 text-xs text-right">
既読
</div>)
}
</div>
</li>
))}
</ul>
<SendButton user={user} job_seeker_jobid={job_seeker_jobid} setReloadFlg={setReloadFlg}></SendButton>
</div>)
}
実装の確認
企業側でメッセージを送信します。
この状態で求職者側で開くと、ヘッダーのメッセージに通知数が表示されます。
メッセージページを開くと、未読のメッセージがあるやり取りに赤丸が追加されます。
今回はリアルタイムの更新はしていないため、
メッセージを開いたうえでリロードすると、未読の通知は双方ともになくなります。
求職者側から逆にメッセージを送ってみます。
再度企業側で開くと、同じように通知が表現されていることが確認できます。
やり取りを開くと、前回のメッセージに既読というテキストが追加され、
リロード後は通知の表示がなくなることが確認できます。
その他参考資料など
弊社では『マッチングワン』という『低コスト・短期にマッチングサービスを構築できる』サービスを展開しており、今回ご紹介するコードは、その『マッチングワン』でも使われているコードとなります。
本記事で紹介したようなプロダクトを開発されたい場合は、是非お問い合わせください。
またTodoONada株式会社では、この記事で紹介した以外にも、Supabase・Next.jsを使ったアプリの作成方法についてご紹介しています。
下記記事にて一覧を見ることができますので、ぜひこちらもご覧ください!https://note.com/embed/notes/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
presented by