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"; import { Trans, useTranslation } from "react-i18next"; import { useDateLocale } from "@/hooks/use-date-locale"; import { useDocDomain } from "@/hooks/use-doc-domain"; 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 { t } = useTranslation(["views/settings"]); const { getLocaleDocUrl } = useDocDomain(); 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", t("notification.unsavedChanges"), 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", t("notification.unsavedRegistrations"), undefined, "registration", ); registration.pushManager .subscribe({ userVisibleOnly: true, applicationServerKey: publicKey, }) .then((pushSubscription) => { axios .post("notifications/register", { sub: pushSubscription, }) .catch(() => { toast.error(t("notification.toast.error.registerFailed"), { position: "top-center", }); pushSubscription.unsubscribe(); registration.unregister(); setRegistration(null); }); toast.success(t("notification.toast.success.registered"), { position: "top-center", }); }); } }, [publicKey, addMessage, t], ); // 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(t("notification.toast.success.settingSaved"), { position: "top-center", }); updateConfig(); } else { toast.error( t("toast.save.error.title", { errorMessage: res.statusText, ns: "common", }), { position: "top-center", }, ); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center", }, ); }) .finally(() => { setIsLoading(false); }); }, [updateConfig, setIsLoading, allCameras, t], ); function onSubmit(values: z.infer) { setIsLoading(true); saveToConfig(values as NotificationSettingsValueType); } useEffect(() => { document.title = t("documentTitle.notifications"); }, [t]); if (!("Notification" in window) || !window.isSecureContext) { return (
{t("notification.notificationSettings.title")}

{t("notification.notificationSettings.desc")}

{t("notification.notificationSettings.documentation")}{" "}
{t("notification.notificationUnavailable.title")} notification.notificationUnavailable.desc
{t("notification.notificationUnavailable.documentation")}{" "}
); } return ( <>
{t("notification.notificationSettings.title")}

{t("notification.notificationSettings.desc")}

{t("notification.notificationSettings.documentation")}{" "}
( {t("notification.email.title")} {t("notification.email.desc")} )} /> ( {allCameras && allCameras?.length > 0 ? ( <>
{t("notification.cameras.title")}
( { 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); }} /> ))}
) : (
{t("notification.cameras.noCameras")}
)} {t("notification.cameras.desc")}
)} />
{t("notification.deviceSpecific")} {registration != null && registration.active && ( )}
{notificationCameras.length > 0 && (
{t("notification.globalSettings.title")}

{t("notification.globalSettings.desc")}

{notificationCameras.map((item) => ( ))}
)}
); } type CameraNotificationSwitchProps = { config?: FrigateConfig; camera: string; }; export function CameraNotificationSwitch({ config, camera, }: CameraNotificationSwitchProps) { const { t } = useTranslation(["views/settings"]); 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 locale = useDateLocale(); const formatSuspendedUntil = (timestamp: string) => { // Some languages require a change in word order if (timestamp === "0") return t("time.untilForRestart", { ns: "common" }); const time = formatUnixTimestampToDateTime(parseInt(timestamp), { time_style: "medium", date_style: "medium", timezone: config?.ui.timezone, date_format: config?.ui.time_format == "24hour" ? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common", }) : t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common", }), locale: locale, }); return t("time.untilForTime", { ns: "common", time }); }; return (
{!isSuspended ? ( ) : ( )}
{!isSuspended ? (
{t("notification.active")}
) : (
{t("notification.suspended", { time: formatSuspendedUntil(notificationSuspendUntil), })}
)}
{!isSuspended ? ( ) : ( )}
); }