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 { Input } from "@/components/ui/input"; 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, useRef, 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 { use24HourTime } from "@/hooks/use-date-utils"; 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"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { cn } from "@/lib/utils"; import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; import set from "lodash/set"; import type { ConfigSectionData, JsonObject } from "@/types/configForm"; import { sanitizeSectionData } from "@/utils/configUtil"; import type { SectionRendererProps } from "./registry"; const NOTIFICATION_SERVICE_WORKER = "/notifications-worker.js"; import { SettingsGroupCard, SPLIT_ROW_CLASS_NAME, CONTROL_COLUMN_CLASS_NAME, } from "@/components/card/SettingsGroupCard"; export default function NotificationsSettingsExtras({ formContext, }: SectionRendererProps) { const { t } = useTranslation([ "views/settings", "common", "components/filter", ]); const { getLocaleDocUrl } = useDocDomain(); // roles const isAdmin = useIsAdmin(); // status bar const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; // config const { data: config } = useSWR("config", { revalidateOnFocus: false, }); const allCameras = useMemo(() => { if (!config) { return []; } return Object.values(config.cameras) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order) .filter((c) => c.enabled_in_config); }, [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(); // notification state const [registration, setRegistration] = useState(); const [cameraSelectionTouched, setCameraSelectionTouched] = useState(false); useEffect(() => { if (!("Notification" in window) || !window.isSecureContext) { return; } navigator.serviceWorker .getRegistration(NOTIFICATION_SERVICE_WORKER) .then((worker) => { if (worker) { // Trigger a check for an updated service worker script worker.update().catch(() => {}); setRegistration(worker); } else { setRegistration(null); } }) .catch(() => { setRegistration(null); }); }, []); // form const formSchema = z.object({ allEnabled: z.boolean(), email: z.string(), cameras: z.array(z.string()), }); const pendingDataBySection = useMemo( () => formContext?.pendingDataBySection ?? {}, [formContext?.pendingDataBySection], ); const pendingCameraOverrides = useMemo(() => { const overrides: Record = {}; Object.entries(pendingDataBySection).forEach(([key, data]) => { if (!key.endsWith("::notifications")) { return; } const cameraName = key.slice(0, key.indexOf("::")); const enabled = (data as JsonObject | undefined)?.enabled; if (typeof enabled === "boolean") { overrides[cameraName] = enabled; } }); return overrides; }, [pendingDataBySection]); const defaultValues = useMemo(() => { const formData = formContext?.formData as JsonObject | undefined; const enabledValue = typeof formData?.enabled === "boolean" ? formData.enabled : (config?.notifications.enabled ?? false); const emailValue = typeof formData?.email === "string" ? formData.email : (config?.notifications.email ?? ""); const baseEnabledSet = new Set( notificationCameras.map((camera) => camera.name), ); const selectedCameras = enabledValue ? [] : allCameras .filter((camera) => { const pendingEnabled = pendingCameraOverrides[camera.name]; if (typeof pendingEnabled === "boolean") { return pendingEnabled; } return baseEnabledSet.has(camera.name); }) .map((camera) => camera.name); return { allEnabled: Boolean(enabledValue), email: typeof emailValue === "string" ? emailValue : "", cameras: selectedCameras, }; }, [ allCameras, config?.notifications.email, config?.notifications.enabled, formContext?.formData, notificationCameras, pendingCameraOverrides, ]); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues, }); const watchAllEnabled = form.watch("allEnabled"); const watchCameras = form.watch("cameras"); const watchEmail = form.watch("email"); const pendingCameraOverridesRef = useRef>(new Set()); const resetFormState = useCallback( (values: z.infer) => { form.reset(values); setCameraSelectionTouched(false); pendingCameraOverridesRef.current.clear(); }, [form], ); // pending changes sync (Undo All / Save All) const hasPendingNotifications = useMemo( () => Object.keys(pendingDataBySection).some( (key) => key === "notifications" || key.endsWith("::notifications"), ), [pendingDataBySection], ); const hasPendingNotificationsRef = useRef(hasPendingNotifications); useEffect(() => { if (!config || form.formState.isDirty || hasPendingNotifications) { return; } resetFormState(defaultValues); }, [ config, defaultValues, form.formState.isDirty, hasPendingNotifications, resetFormState, ]); useEffect(() => { const hadPending = hasPendingNotificationsRef.current; hasPendingNotificationsRef.current = hasPendingNotifications; if (hadPending && !hasPendingNotifications) { resetFormState(defaultValues); } }, [hasPendingNotifications, defaultValues, resetFormState]); useEffect(() => { if (!formContext?.onFormDataChange) { return; } const baseData = (formContext.formData as JsonObject | undefined) ?? (config?.notifications as JsonObject | undefined); if (!baseData) { return; } const nextData = cloneDeep(baseData); const normalizedEmail = watchEmail?.trim() ? watchEmail : null; set(nextData, "enabled", Boolean(watchAllEnabled)); set(nextData, "email", normalizedEmail); formContext.onFormDataChange(nextData as ConfigSectionData); }, [config, formContext, watchAllEnabled, watchEmail]); // camera selection overrides const baselineCameraSelection = useMemo(() => { if (!config) { return [] as string[]; } return config.notifications.enabled ? [] : notificationCameras.map((camera) => camera.name); }, [config, notificationCameras]); const cameraSelectionDirty = useMemo(() => { const current = Array.isArray(watchCameras) ? watchCameras : []; return !isEqual([...current].sort(), [...baselineCameraSelection].sort()); }, [watchCameras, baselineCameraSelection]); useEffect(() => { formContext?.setExtraHasChanges?.(cameraSelectionDirty); }, [cameraSelectionDirty, formContext]); useEffect(() => { const onPendingDataChange = formContext?.onPendingDataChange; if (!onPendingDataChange || !config) { return; } if (!cameraSelectionTouched) { return; } if (!cameraSelectionDirty) { pendingCameraOverridesRef.current.forEach((cameraName) => { onPendingDataChange("notifications", cameraName, null); }); pendingCameraOverridesRef.current.clear(); setCameraSelectionTouched(false); return; } const selectedCameras = Array.isArray(watchCameras) ? watchCameras : []; allCameras.forEach((camera) => { const desiredEnabled = watchAllEnabled ? true : selectedCameras.includes(camera.name); const currentNotifications = config.cameras[camera.name]?.notifications; const currentEnabled = currentNotifications?.enabled; if (desiredEnabled === currentEnabled) { if (pendingCameraOverridesRef.current.has(camera.name)) { onPendingDataChange("notifications", camera.name, null); pendingCameraOverridesRef.current.delete(camera.name); } return; } if (!currentNotifications) { return; } const nextNotifications = cloneDeep( currentNotifications as JsonObject, ) as JsonObject; set(nextNotifications, "enabled", desiredEnabled); const sanitizedNotifications = sanitizeSectionData( nextNotifications as ConfigSectionData, ["enabled_in_config", "email"], ); onPendingDataChange("notifications", camera.name, sanitizedNotifications); pendingCameraOverridesRef.current.add(camera.name); }); }, [ allCameras, cameraSelectionDirty, cameraSelectionTouched, config, formContext, watchAllEnabled, watchCameras, ]); const anyCameraNotificationsEnabled = useMemo( () => config && Object.values(config.cameras).some( (c) => c.enabled_in_config && c.notifications && c.notifications.enabled_in_config, ), [config], ); const shouldFetchPubKey = Boolean( config && (config.notifications?.enabled || anyCameraNotificationsEnabled) && (watchAllEnabled || (Array.isArray(watchCameras) && watchCameras.length > 0)), ); const { data: publicKey } = useSWR( shouldFetchPubKey ? "notifications/pubkey" : null, { revalidateOnFocus: false }, ); const subscribeToNotifications = useCallback( (workerRegistration: ServiceWorkerRegistration) => { if (!workerRegistration) { return; } addMessage( "notification_settings", t("notification.unsavedRegistrations"), undefined, "registration", ); workerRegistration.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(); workerRegistration.unregister(); setRegistration(null); }); toast.success(t("notification.toast.success.registered"), { position: "top-center", }); }); }, [addMessage, publicKey, t], ); useEffect(() => { if (watchCameras.length > 0) { form.setValue("allEnabled", false); } }, [watchCameras, allCameras, form]); useEffect(() => { document.title = t("documentTitle.notifications"); }, [t]); if (formContext?.level && formContext.level !== "global") { return null; } if (!config) { return ; } if (!("Notification" in window) || !window.isSecureContext) { return (

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

{t("readTheDocumentation", { ns: "common" })}
{t("notification.notificationUnavailable.title")} notification.notificationUnavailable.desc
{t("readTheDocumentation", { ns: "common" })}{" "}
); } return (
{isAdmin && (
(
{t("notification.email.title")} {t("notification.email.desc")}
{t("notification.email.desc")}
)} /> (
{t("notification.cameras.title")} {t("notification.cameras.desc")}
{allCameras.length > 0 ? (
( { setCameraSelectionTouched(true); if (checked) { form.setValue("cameras", []); } allEnabledField.onChange(checked); }} /> )} /> {allCameras.map((camera) => { const currentCameras = Array.isArray( field.value, ) ? field.value : []; return ( { setCameraSelectionTouched(true); const newCameras = checked ? Array.from( new Set([ ...currentCameras, camera.name, ]), ) : currentCameras.filter( (value) => value !== camera.name, ); field.onChange(newCameras); form.setValue("allEnabled", false); }} /> ); })}
) : (
{t("notification.cameras.noCameras")}
)} {t("notification.cameras.desc")}
)} />
)}
{isAdmin && registration != null && registration.active && ( )}
{isAdmin && notificationCameras.length > 0 && (

{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 [customDialogOpen, setCustomDialogOpen] = useState(false); const handleSuspend = (duration: string) => { setIsSuspended(true); if (duration == "off") { sendNotification("OFF"); } else { sendNotificationSuspend(parseInt(duration)); } }; const handleCustomSuspend = (totalMinutes: number) => { setIsSuspended(true); sendNotificationSuspend(totalMinutes); }; const handleCancelSuspension = () => { sendNotification("ON"); sendNotificationSuspend(0); }; const locale = useDateLocale(); const is24Hour = use24HourTime(config); const formatSuspendedUntil = (timestamp: string) => { 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: is24Hour ? 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 ? (
) : ( )}
); }