"use client"; import { useEffect, useState } from "react"; import { BellRing, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { apiFetchRaw } from "@/lib/api/api-fetch"; /** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */ function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(base64); const buffer = new ArrayBuffer(raw.length); const view = new Uint8Array(buffer); for (let i = 0; i < raw.length; ++i) { view[i] = raw.charCodeAt(i); } return buffer; } export function WebPushCard() { const { t } = useTranslations(); const [isSubscribed, setIsSubscribed] = useState(false); const [isSupported, setIsSupported] = useState(true); const [isDenied, setIsDenied] = useState(false); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); useEffect(() => { // Async I/O (browser API + service worker registration). The lint rule // allows setState inside async callbacks — only synchronous setState in // the effect body is rejected. async function checkSubscription() { if (!("serviceWorker" in navigator) || !("PushManager" in window)) { setIsSupported(false); setLoading(false); return; } if (Notification.permission === "denied") { setIsDenied(true); setLoading(false); return; } try { const registration = await navigator.serviceWorker.getRegistration("/sw.js"); if (registration) { const subscription = await registration.pushManager.getSubscription(); setIsSubscribed(!!subscription); } } catch { // Ignore errors during check } setLoading(false); } void checkSubscription(); }, []); async function handleSubscribe() { setActionLoading(true); setMsg(null); setMsgType(null); try { const vapidRes = await apiFetchRaw("/api/notifications/vapid"); if (!vapidRes.ok) { setMsg(t("settings.webPushNotConfigured")); setMsgType("error"); setActionLoading(false); return; } const vapidData = await vapidRes.json(); const vapidPublicKey = vapidData.data.publicKey; // Defer to the app-wide registration (`` in // the root providers) rather than re-registering here — same scope, // so a second `register("/sw.js")` would only return the existing // worker. `ready` resolves with the active registration once it is // controlling the page. const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey), }); const subJson = subscription.toJSON(); const res = await apiFetchRaw("/api/notifications/web-push", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: subJson.endpoint, keys: { p256dh: subJson.keys?.p256dh ?? "", auth: subJson.keys?.auth ?? "", }, }), }); if (res.ok) { setIsSubscribed(true); setMsg(t("settings.webPushSubscribed")); setMsgType("success"); } else { setMsg(t("settings.webPushSubscribeFailed")); setMsgType("error"); } } catch { if (Notification.permission === "denied") { setIsDenied(true); setMsg(t("settings.webPushDenied")); } else { setMsg(t("settings.webPushSubscribeFailed")); } setMsgType("error"); } finally { setActionLoading(false); } } async function handleUnsubscribe() { setActionLoading(true); setMsg(null); setMsgType(null); try { const registration = await navigator.serviceWorker.getRegistration("/sw.js"); if (registration) { const subscription = await registration.pushManager.getSubscription(); if (subscription) { await apiFetchRaw("/api/notifications/web-push", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: subscription.endpoint }), }); await subscription.unsubscribe(); } } setIsSubscribed(false); setMsg(t("settings.webPushUnsubscribed")); setMsgType("success"); } catch { setMsg(t("settings.webPushSubscribeFailed")); setMsgType("error"); } finally { setActionLoading(false); } } // The primary action sits in the header row (right of the title) rather than // bottom-left, so an inactive card does not waste a wide empty band before // its enable button. The loading spinner rides the same slot; the // unsupported / denied notices stay in the body. const headerAction = loading ? ( ) : !isSupported || isDenied ? null : (
{isSubscribed ? ( ) : ( )} {isSubscribed && ( )}
); return ( {(!loading && !isSupported) || (!loading && isDenied) || msg ? (
{!loading && !isSupported ? (

{t("settings.webPushNotSupported")}

) : !loading && isDenied ? (

{t("settings.webPushDenied")}

) : null} {msg && (

{msg}

)}
) : null}
); }