Add Web Push warning notification backend
This commit is contained in:
@@ -8,11 +8,12 @@ import { LanguageToggle } from "@/components/ui/language-toggle";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { DEFAULT_STATION_ID } from "@/lib/constants";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { decodeBase64UrlKey, deletePushSubscription, fetchVapidPublicKey, savePushSubscription } from "@/lib/notification-api";
|
||||
import { formatProvinceName, getProvinceForSelection, PROVINCES } from "@/lib/provinces";
|
||||
import { useWeatherStore } from "@/lib/store";
|
||||
import type { Province } from "@/types/province";
|
||||
|
||||
type NotificationSupportStatus = "checking" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
|
||||
type NotificationSupportStatus = "checking" | "unconfigured" | "unsupported" | "needs-install" | "default" | "denied" | "granted";
|
||||
|
||||
function isStandaloneApp() {
|
||||
if (typeof window === "undefined") return false;
|
||||
@@ -47,6 +48,9 @@ function SettingsRow({ icon: Icon, title, description, children }: { icon: typeo
|
||||
export function SettingsPage() {
|
||||
const { language, t } = useI18n();
|
||||
const [notificationStatus, setNotificationStatus] = useState<NotificationSupportStatus>("checking");
|
||||
const [vapidPublicKey, setVapidPublicKey] = useState<string | null>(null);
|
||||
const [isSavingSubscription, setIsSavingSubscription] = useState(false);
|
||||
const [notificationMessage, setNotificationMessage] = useState<string | null>(null);
|
||||
const selectedStationId = useWeatherStore((state) => state.selectedStationId);
|
||||
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
||||
const notificationsEnabled = useWeatherStore((state) => state.warningNotificationsEnabled);
|
||||
@@ -61,12 +65,44 @@ export function SettingsPage() {
|
||||
const effectiveProvinceLabel = effectiveProvince ? formatProvinceName(effectiveProvince, language) : t("settings.notifications.noProvince");
|
||||
|
||||
useEffect(() => {
|
||||
const animationFrame = window.requestAnimationFrame(() => setNotificationStatus(getNotificationSupportStatus()));
|
||||
return () => window.cancelAnimationFrame(animationFrame);
|
||||
const abortController = new AbortController();
|
||||
const animationFrame = window.requestAnimationFrame(() => {
|
||||
setNotificationStatus(getNotificationSupportStatus());
|
||||
fetchVapidPublicKey(abortController.signal)
|
||||
.then((config) => {
|
||||
setVapidPublicKey(config.publicKey);
|
||||
if (!config.configured) setNotificationStatus("unconfigured");
|
||||
})
|
||||
.catch(() => setNotificationStatus("unconfigured"));
|
||||
});
|
||||
return () => {
|
||||
abortController.abort();
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notificationsEnabled || !vapidPublicKey || !effectiveProvince || notificationStatus !== "granted") return;
|
||||
const province = effectiveProvince;
|
||||
let cancelled = false;
|
||||
|
||||
async function syncSubscriptionProvince() {
|
||||
const registration = await navigator.serviceWorker?.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!subscription || cancelled) return;
|
||||
await savePushSubscription(subscription, province, language).catch(() => undefined);
|
||||
}
|
||||
|
||||
syncSubscriptionProvince();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveProvince, language, notificationStatus, notificationsEnabled, vapidPublicKey]);
|
||||
|
||||
const notificationStatusLabel = useMemo(() => {
|
||||
switch (notificationStatus) {
|
||||
case "unconfigured":
|
||||
return t("settings.notifications.statusUnconfigured");
|
||||
case "unsupported":
|
||||
return t("settings.notifications.statusUnsupported");
|
||||
case "needs-install":
|
||||
@@ -82,17 +118,65 @@ export function SettingsPage() {
|
||||
}
|
||||
}, [notificationStatus, t]);
|
||||
|
||||
const canRequestPermission = notificationStatus === "default";
|
||||
const canEnablePreference = notificationStatus === "granted";
|
||||
const canUsePush = Boolean(vapidPublicKey && effectiveProvince && notificationStatus !== "unsupported" && notificationStatus !== "needs-install" && notificationStatus !== "denied" && notificationStatus !== "unconfigured");
|
||||
|
||||
async function requestNotificationPermission() {
|
||||
async function getServiceWorkerRegistration() {
|
||||
if (!("serviceWorker" in navigator)) throw new Error("Service worker is not available.");
|
||||
return await navigator.serviceWorker.getRegistration() ?? await navigator.serviceWorker.register("/sw.js");
|
||||
}
|
||||
|
||||
async function enableWarningNotifications() {
|
||||
if (!vapidPublicKey || !effectiveProvince) {
|
||||
setNotificationMessage(t("settings.notifications.saveError"));
|
||||
return;
|
||||
}
|
||||
if (!("Notification" in window)) {
|
||||
setNotificationStatus("unsupported");
|
||||
return;
|
||||
}
|
||||
const permission = await Notification.requestPermission();
|
||||
setNotificationStatus(permission);
|
||||
setNotificationsEnabled(permission === "granted");
|
||||
setIsSavingSubscription(true);
|
||||
setNotificationMessage(null);
|
||||
try {
|
||||
const permission = Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
|
||||
setNotificationStatus(permission);
|
||||
if (permission !== "granted") {
|
||||
setNotificationsEnabled(false);
|
||||
return;
|
||||
}
|
||||
const registration = await getServiceWorkerRegistration();
|
||||
const existingSubscription = await registration.pushManager.getSubscription();
|
||||
const subscription = existingSubscription ?? await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeBase64UrlKey(vapidPublicKey),
|
||||
});
|
||||
await savePushSubscription(subscription, effectiveProvince, language);
|
||||
setNotificationsEnabled(true);
|
||||
setNotificationMessage(t("settings.notifications.saveSuccess"));
|
||||
} catch {
|
||||
setNotificationsEnabled(false);
|
||||
setNotificationMessage(t("settings.notifications.saveError"));
|
||||
} finally {
|
||||
setIsSavingSubscription(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableWarningNotifications() {
|
||||
setIsSavingSubscription(true);
|
||||
setNotificationMessage(null);
|
||||
try {
|
||||
const registration = await navigator.serviceWorker?.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
await deletePushSubscription(subscription.endpoint).catch(() => undefined);
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
setNotificationsEnabled(false);
|
||||
setNotificationMessage(t("settings.notifications.disableSuccess"));
|
||||
} catch {
|
||||
setNotificationMessage(t("settings.notifications.saveError"));
|
||||
} finally {
|
||||
setIsSavingSubscription(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -186,26 +270,20 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-card border border-border/60 bg-surface px-3 py-3">
|
||||
<label className="flex items-center justify-between gap-4 text-sm">
|
||||
<span>
|
||||
<span className="block font-medium">{t("settings.notifications.enable")}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationsEnabled}
|
||||
disabled={!canEnablePreference}
|
||||
onChange={(event) => setNotificationsEnabled(event.target.checked)}
|
||||
className="size-5 accent-accent"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-4 rounded-card border border-border/60 bg-surface px-3 py-3 text-sm">
|
||||
<p className="font-medium">{t("settings.notifications.enable")}</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted">{t("settings.notifications.enableDescription")}</p>
|
||||
{notificationMessage && <p className="mt-3 text-xs font-medium text-accent">{notificationMessage}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="button" disabled={!canRequestPermission} onClick={requestNotificationPermission}>
|
||||
<Button type="button" disabled={(!notificationsEnabled && !canUsePush) || isSavingSubscription} onClick={notificationsEnabled ? disableWarningNotifications : enableWarningNotifications}>
|
||||
<Bell className="size-4" />
|
||||
{t("settings.notifications.requestPermission")}
|
||||
{isSavingSubscription
|
||||
? t("settings.notifications.saving")
|
||||
: notificationsEnabled
|
||||
? t("settings.notifications.disable")
|
||||
: t("settings.notifications.enableDevice")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user