import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import Heading from "@/components/ui/heading"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Toaster } from "@/components/ui/sonner"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { FrigateConfig } from "@/types/frigateConfig"; import { zodResolver } from "@hookform/resolvers/zod"; import axios from "axios"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; import { CiCircleAlert } from "react-icons/ci"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import useSWR from "swr"; import { z } from "zod"; import { useNotifications, useNotificationSuspend, useNotificationTest, } from "@/api/ws"; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from "@/components/ui/select"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js"; type NotificationSettingsValueType = { allEnabled: boolean; email?: string; cameras: string[]; }; type NotificationsSettingsViewProps = { setUnsavedChanges: React.Dispatch>; }; export default function NotificationView({ setUnsavedChanges, }: NotificationsSettingsViewProps) { const { data: config, mutate: updateConfig } = useSWR( "config", { revalidateOnFocus: false, }, ); const allCameras = useMemo(() => { if (!config) { return []; } return Object.values(config.cameras).sort( (aConf, bConf) => aConf.ui.order - bConf.ui.order, ); }, [config]); const notificationCameras = useMemo(() => { if (!config) { return []; } return Object.values(config.cameras) .filter( (conf) => conf.enabled_in_config && conf.notifications && conf.notifications.enabled_in_config, ) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); const { send: sendTestNotification } = useNotificationTest(); // status bar const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; const [changedValue, setChangedValue] = useState(false); useEffect(() => { if (changedValue) { addMessage( "notification_settings", `Unsaved notification settings`, undefined, `notification_settings`, ); } else { removeMessage("notification_settings", `notification_settings`); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [changedValue]); // notification key handling const { data: publicKey } = useSWR( config?.notifications?.enabled ? "notifications/pubkey" : null, { revalidateOnFocus: false }, ); const subscribeToNotifications = useCallback( (registration: ServiceWorkerRegistration) => { if (registration) { addMessage( "notification_settings", "Unsaved Notification Registrations", undefined, "registration", ); registration.pushManager .subscribe({ userVisibleOnly: true, applicationServerKey: publicKey, }) .then((pushSubscription) => { axios .post("notifications/register", { sub: pushSubscription, }) .catch(() => { toast.error("Failed to save notification registration.", { position: "top-center", }); pushSubscription.unsubscribe(); registration.unregister(); setRegistration(null); }); toast.success( "Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.", { position: "top-center", }, ); }); } }, [publicKey, addMessage], ); // notification state const [registration, setRegistration] = useState(); useEffect(() => { if (!("Notification" in window) || !window.isSecureContext) { return; } navigator.serviceWorker .getRegistration(NOTIFICATION_SERVICE_WORKER) .then((worker) => { if (worker) { setRegistration(worker); } else { setRegistration(null); } }) .catch(() => { setRegistration(null); }); }, []); // form const [isLoading, setIsLoading] = useState(false); const formSchema = z.object({ allEnabled: z.boolean(), email: z.string(), cameras: z.array(z.string()), }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { allEnabled: config?.notifications.enabled, email: config?.notifications.email, cameras: config?.notifications.enabled ? [] : notificationCameras.map((c) => c.name), }, }); const watchCameras = form.watch("cameras"); useEffect(() => { if (watchCameras.length > 0) { form.setValue("allEnabled", false); } }, [watchCameras, allCameras, form]); const onCancel = useCallback(() => { if (!config) { return; } setUnsavedChanges(false); setChangedValue(false); form.reset({ allEnabled: config.notifications.enabled, email: config.notifications.email || "", cameras: config?.notifications.enabled ? [] : notificationCameras.map((c) => c.name), }); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [config, removeMessage, setUnsavedChanges]); const saveToConfig = useCallback( async ( { allEnabled, email, cameras }: NotificationSettingsValueType, // values submitted via the form ) => { const allCameraNames = allCameras.map((cam) => cam.name); const enabledCameraQueries = cameras .map((cam) => `&cameras.${cam}.notifications.enabled=True`) .join(""); const disabledCameraQueries = allCameraNames .filter((cam) => !cameras.includes(cam)) .map( (cam) => `&cameras.${cam}.notifications.enabled=${allEnabled ? "True" : "False"}`, ) .join(""); const allCameraQueries = enabledCameraQueries + disabledCameraQueries; axios .put( `config/set?notifications.enabled=${allEnabled ? "True" : "False"}¬ifications.email=${email}${allCameraQueries}`, { requires_restart: 0, }, ) .then((res) => { if (res.status === 200) { toast.success("Notification settings have been saved.", { position: "top-center", }); updateConfig(); } else { toast.error(`Failed to save config changes: ${res.statusText}`, { position: "top-center", }); } }) .catch((error) => { toast.error( `Failed to save config changes: ${error.response.data.message}`, { position: "top-center" }, ); }) .finally(() => { setIsLoading(false); }); }, [updateConfig, setIsLoading, allCameras], ); function onSubmit(values: z.infer) { setIsLoading(true); saveToConfig(values as NotificationSettingsValueType); } if (!("Notification" in window) || !window.isSecureContext) { return (
Notification Settings

Frigate can natively send push notifications to your device when it is running in the browser or installed as a PWA.

Read the Documentation{" "}
Notifications Unavailable Web push notifications require a secure context ( https://...). This is a browser limitation. Access Frigate securely to use notifications.
Read the Documentation{" "}
); } return ( <>
Notification Settings

Frigate can natively send push notifications to your device when it is running in the browser or installed as a PWA.

Read the Documentation{" "}
( Email Entering a valid email is required, as this is used by the push server in case problems occur. )} /> ( {allCameras && allCameras?.length > 0 ? ( <>
Cameras
( { setChangedValue(true); if (checked) { form.setValue("cameras", []); } field.onChange(checked); }} /> )} /> {allCameras?.map((camera) => ( { setChangedValue(true); let newCameras; if (checked) { newCameras = [ ...field.value, camera.name, ]; } else { newCameras = field.value?.filter( (value) => value !== camera.name, ); } field.onChange(newCameras); form.setValue("allEnabled", false); }} /> ))}
) : (
No cameras available.
)} Select the cameras to enable notifications for.
)} />
Device-Specific Settings {registration != null && registration.active && ( )}
{notificationCameras.length > 0 && (
Global Settings

Temporarily suspend notifications for specific cameras on all registered devices.

{notificationCameras.map((item) => ( ))}
)}
); } type CameraNotificationSwitchProps = { config?: FrigateConfig; camera: string; }; export function CameraNotificationSwitch({ config, camera, }: CameraNotificationSwitchProps) { const { payload: notificationState, send: sendNotification } = useNotifications(camera); const { payload: notificationSuspendUntil, send: sendNotificationSuspend } = useNotificationSuspend(camera); const [isSuspended, setIsSuspended] = useState(false); useEffect(() => { if (notificationSuspendUntil) { setIsSuspended( notificationSuspendUntil !== "0" || notificationState === "OFF", ); } }, [notificationSuspendUntil, notificationState]); const handleSuspend = (duration: string) => { setIsSuspended(true); if (duration == "off") { sendNotification("OFF"); } else { sendNotificationSuspend(parseInt(duration)); } }; const handleCancelSuspension = () => { sendNotification("ON"); sendNotificationSuspend(0); }; const formatSuspendedUntil = (timestamp: string) => { if (timestamp === "0") return "Frigate restarts."; return formatUnixTimestampToDateTime(parseInt(timestamp), { time_style: "medium", date_style: "medium", timezone: config?.ui.timezone, strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`, }); }; return (
{!isSuspended ? ( ) : ( )}
{!isSuspended ? (
Notifications Active
) : (
Notifications suspended until{" "} {formatSuspendedUntil(notificationSuspendUntil)}
)}
{!isSuspended ? ( ) : ( )}
); }