Files
buzz-sheet/components/notifications/notification-bell.tsx
T
2026-10-13 00:19:13 +07:00

329 lines
11 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import Link from "next/link";
import Image from "@/components/ui/resilient-image";
import { BellIcon, BellOffIcon, SettingsIcon } from "lucide-react";
import { authClient } from "@/lib/auth/client";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useSitePushDisabled } from "./push-client";
type Item = {
id: string;
title: string;
body: string;
url: string;
readAt: string | null;
authorName: string | null;
authorImage: string | null;
imageUrl: string | null;
};
type Feed = { items: Item[]; unread: number; nextCursor: string | null };
const empty: Feed = { items: [], unread: 0, nextCursor: null };
type State = Feed & {
signedIn: boolean;
busy: boolean;
error: string;
refresh: (more?: boolean) => Promise<Feed | undefined>;
read: (id?: string) => Promise<void>;
};
const Context = createContext<State>({
...empty,
signedIn: false,
busy: false,
error: "",
refresh: async () => undefined,
read: async () => {},
});
export function NotificationProvider({ children }: { children: ReactNode }) {
const { data } = authClient.useSession();
const userId = data?.user.id;
const [feed, setFeed] = useState<Feed>(empty);
const [owner, setOwner] = useState<string | undefined>(undefined);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const current = useRef(feed);
useEffect(() => {
current.current = feed;
}, [feed]);
const sequence = useRef(0);
const invalidate = useCallback(() => {
++sequence.current;
}, []);
const refresh = useCallback(
async (more = false): Promise<Feed | undefined> => {
if (!userId) return;
const request = ++sequence.current;
const cursor = more ? current.current.nextCursor : null;
setBusy(true);
try {
const response = await fetch(
`/api/notifications${cursor ? `?before=${encodeURIComponent(cursor)}` : ""}`,
{ cache: "no-store" },
);
if (!response.ok) throw new Error("โหลดการแจ้งเตือนไม่สำเร็จ");
const result = (await response.json()) as Feed;
if (sequence.current !== request) return;
setFeed((previous) => ({
...result,
items: more
? [
...previous.items,
...result.items.filter(
(item) => !previous.items.some((old) => old.id === item.id),
),
]
: result.items,
}));
setOwner(userId);
setError("");
return result;
} catch (cause) {
if (sequence.current === request)
setError(
cause instanceof Error
? cause.message
: "โหลดการแจ้งเตือนไม่สำเร็จ",
);
} finally {
if (sequence.current === request) setBusy(false);
}
},
[userId],
);
useEffect(() => {
if (!userId) return;
void Promise.resolve().then(() => refresh());
const source = new EventSource("/api/notifications/events");
source.addEventListener("changed", () => void refresh());
source.addEventListener("open", () => void refresh());
return () => {
invalidate();
source.close();
};
}, [userId, refresh, invalidate]);
const read = useCallback(
async (id?: string) => {
if (!userId) return;
setFeed((previous) => ({
...previous,
unread: id
? Math.max(
0,
previous.unread -
(previous.items.some((item) => item.id === id && !item.readAt)
? 1
: 0),
)
: 0,
items: previous.items.map((item) =>
!id || item.id === id
? { ...item, readAt: new Date().toISOString() }
: item,
),
}));
try {
const response = await fetch("/api/notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(id ? { id } : {}),
});
if (!response.ok) throw new Error("บันทึกการอ่านไม่สำเร็จ");
} finally {
await refresh();
}
},
[userId, refresh],
);
return (
<Context
value={{
...(owner === userId ? feed : empty),
signedIn: Boolean(userId),
busy,
error,
refresh,
read,
}}
>
{children}
</Context>
);
}
export function NotificationBell() {
const state = useContext(Context);
const disabled = useSitePushDisabled();
const [open, setOpen] = useState(false);
return (
<Popover
open={open}
onOpenChange={(value) => {
setOpen(value);
if (value) {
if (state.unread > 0) void state.read().catch(() => undefined);
else
void state.refresh().then((result) => {
if (result?.unread) void state.read().catch(() => undefined);
});
}
}}
>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon"
className="relative shrink-0"
aria-label={`การแจ้งเตือน${disabled ? " ปิดชั่วคราว" : state.unread ? ` ใหม่ ${state.unread} รายการ` : ""}`}
/>
}
>
{disabled ? <BellOffIcon /> : <BellIcon />}
{!disabled && state.unread > 0 && (
<Badge
variant="destructive"
className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full bg-destructive px-1 text-background dark:bg-destructive"
>
{state.unread > 9 ? "9+" : state.unread}
</Badge>
)}
</PopoverTrigger>
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
<div className="flex items-center justify-between gap-2">
<h2 className="font-semibold">การแจ้งเตือน</h2>
<Button
variant="ghost"
size="icon-sm"
aria-label="ตั้งค่าการแจ้งเตือน"
onClick={() => {
setOpen(false);
window.dispatchEvent(new Event("notifications:enable"));
}}
>
<SettingsIcon />
</Button>
</div>
{!state.signedIn ? (
<Link href="/auth/login" className="text-sm text-primary underline">
เข้าสู่ระบบเพื่อดูการแจ้งเตือน
</Link>
) : (
<>
{state.unread > 0 && (
<Button
variant="link"
size="sm"
className="self-start"
onClick={() => void state.read().catch(() => undefined)}
>
อ่านทั้งหมด
</Button>
)}
<div className="flex max-h-[60svh] flex-col gap-1 overflow-y-auto">
{state.items.map((item) => (
<Link
key={item.id}
href={item.url}
prefetch={false}
className={cn(
"flex items-start gap-3 rounded-lg p-3",
!item.readAt ? "bg-accent" : "hover:bg-muted",
)}
onClick={() => {
setOpen(false);
void state.read(item.id).catch(() => undefined);
}}
>
{item.imageUrl ? (
<Image
src={item.imageUrl}
alt=""
width={64}
height={64}
sizes="64px"
className="size-16 shrink-0 rounded-lg object-contain"
/>
) : (
(item.authorName || item.authorImage) && (
<Avatar size="lg">
<AvatarImage
src={item.authorImage ?? undefined}
alt=""
/>
<AvatarFallback>
{item.authorName?.slice(0, 2) || "?"}
</AvatarFallback>
</Avatar>
)
)}
<div className="min-w-0 flex-1">
<p className="wrap-break-word text-sm font-medium">
{!item.readAt && (
<span
className="mr-1 text-destructive"
aria-label="ยังไม่ได้อ่าน"
>
●
</span>
)}
{item.title}
</p>
<p className="line-clamp-2 wrap-break-word text-xs text-muted-foreground">
{item.body}
</p>
</div>
</Link>
))}
{state.busy && !state.items.length && (
<div
role="status"
aria-label="กำลังโหลดการแจ้งเตือน"
className="flex flex-col gap-2"
>
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
)}
{!state.busy && !state.items.length && !state.error && (
<p className="py-6 text-center text-sm text-muted-foreground">
ยังไม่มีการแจ้งเตือน
</p>
)}
{state.error && (
<p role="alert" className="text-sm text-destructive">
{state.error}
</p>
)}
{state.nextCursor && (
<Button
variant="ghost"
disabled={state.busy}
onClick={() => void state.refresh(true)}
>
แสดงเพิ่มเติม
</Button>
)}
</div>
</>
)}
</PopoverContent>
</Popover>
);
}