diff --git a/web/src/components/config-form/section-configs/notifications.ts b/web/src/components/config-form/section-configs/notifications.ts index c43b83f53..68fd78f78 100644 --- a/web/src/components/config-form/section-configs/notifications.ts +++ b/web/src/components/config-form/section-configs/notifications.ts @@ -9,6 +9,15 @@ const notifications: SectionConfigOverrides = { hiddenFields: ["enabled_in_config"], advancedFields: [], }, + global: { + uiSchema: { + "ui:before": { render: "NotificationsSettingsExtras" }, + enabled: { "ui:widget": "hidden" }, + email: { "ui:widget": "hidden" }, + cooldown: { "ui:widget": "hidden" }, + enabled_in_config: { "ui:widget": "hidden" }, + }, + }, camera: { hiddenFields: ["enabled_in_config", "email"], }, diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx new file mode 100644 index 000000000..13e7f99cb --- /dev/null +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -0,0 +1,837 @@ +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 { 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, + 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 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 { 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"; + +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) { + 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.title")} + +
+
+

{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")} + + + + )} + /> + + ( + + {allCameras && allCameras?.length > 0 ? ( + <> +
+ + {t("notification.cameras.title")} + +
+
+ ( + { + setCameraSelectionTouched(true); + if (checked) { + form.setValue("cameras", []); + } + field.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")} + +
+ )} + /> +
+
+ )} +
+ +
+
+
+ + + {t("notification.deviceSpecific")} + + + {isAdmin && registration != null && registration.active && ( + + )} +
+
+ {isAdmin && 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) => { + 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 ? ( + + ) : ( + + )} +
+ ); +} diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts index f37e97b3b..08e3dd86a 100644 --- a/web/src/components/config-form/sectionExtras/registry.ts +++ b/web/src/components/config-form/sectionExtras/registry.ts @@ -2,6 +2,7 @@ import type { ComponentType } from "react"; import SemanticSearchReindex from "./SemanticSearchReindex.tsx"; import CameraReviewStatusToggles from "./CameraReviewStatusToggles"; import ProxyRoleMap from "./ProxyRoleMap"; +import NotificationsSettingsExtras from "./NotificationsSettingsExtras"; import type { ConfigFormContext } from "@/types/configForm"; // Props that will be injected into all section renderers @@ -48,6 +49,9 @@ export const sectionRenderers: SectionRenderers = { proxy: { ProxyRoleMap, }, + notifications: { + NotificationsSettingsExtras, + }, }; export default sectionRenderers; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 493cd50ac..031e3101f 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -193,6 +193,11 @@ export function ConfigSection({ pendingDataBySection !== undefined ? (pendingDataBySection[pendingDataKey] as ConfigSectionData | null) : localPendingData; + const pendingDataRef = useRef(null); + + useEffect(() => { + pendingDataRef.current = pendingData; + }, [pendingData]); const setPendingData = useCallback( (data: ConfigSectionData | null) => { @@ -205,6 +210,7 @@ export function ConfigSection({ [onPendingDataChange, sectionPath, cameraName], ); const [isSaving, setIsSaving] = useState(false); + const [extraHasChanges, setExtraHasChanges] = useState(false); const [formKey, setFormKey] = useState(0); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false); @@ -356,9 +362,11 @@ export function ConfigSection({ // Track if there are unsaved changes const hasChanges = useMemo(() => { - if (!pendingData) return false; - return !isEqual(formData, pendingData); - }, [formData, pendingData]); + const pendingChanged = pendingData + ? !isEqual(formData, pendingData) + : false; + return pendingChanged || extraHasChanges; + }, [formData, pendingData, extraHasChanges]); useEffect(() => { onStatusChange?.({ hasChanges, isOverridden }); @@ -462,6 +470,7 @@ export function ConfigSection({ setPendingData(null); setPendingOverrides(undefined); setDirtyOverrides(undefined); + setExtraHasChanges(false); setFormKey((prev) => prev + 1); }, [setPendingData, setPendingOverrides, setDirtyOverrides]); @@ -649,6 +658,7 @@ export function ConfigSection({ ); setPendingData(null); + setExtraHasChanges(false); refreshConfig(); } catch { toast.error( @@ -714,10 +724,11 @@ export function ConfigSection({ selectedCamera={cameraName} setUnsavedChanges={(hasChanges: boolean) => { // Translate setUnsavedChanges to pending data state - if (hasChanges && !pendingData) { + const currentPending = pendingDataRef.current; + if (hasChanges && !currentPending) { // Component signaled changes but we don't have pending data yet // This can happen when the component manages its own state - } else if (!hasChanges && pendingData) { + } else if (!hasChanges && currentPending) { // Component signaled no changes, clear pending setPendingData(null); } @@ -726,13 +737,7 @@ export function ConfigSection({ ), ]), ); - }, [ - sectionConfig?.renderers, - sectionPath, - cameraName, - pendingData, - setPendingData, - ]); + }, [sectionConfig?.renderers, sectionPath, cameraName, setPendingData]); if (!modifiedSchema) { return null; @@ -787,9 +792,13 @@ export function ConfigSection({ globalValue, cameraValue, hasChanges, + extraHasChanges, + setExtraHasChanges, overrides: uiOverrides as JsonValue | undefined, formData: currentFormData as ConfigSectionData, baselineFormData: effectiveBaselineFormData as ConfigSectionData, + pendingDataBySection, + onPendingDataChange, onFormDataChange: (data: ConfigSectionData) => handleChange(data), // For widgets that need access to full camera config (e.g., zone names) fullCameraConfig: @@ -811,7 +820,6 @@ export function ConfigSection({ }} /> - {/* Save button */}
) : ( - <>{t("button.save", { ns: "common", defaultValue: "Save" })} + t("button.save", { ns: "common", defaultValue: "Save" }) )}
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index d60762b10..8d486a558 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -38,7 +38,6 @@ import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import UsersView from "@/views/settings/UsersView"; import RolesView from "@/views/settings/RolesView"; -import NotificationView from "@/views/settings/NotificationsSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView"; @@ -238,6 +237,7 @@ const SystemDetectorHardwareSettingsPage = createSectionPage( "global", ); const SystemDetectionModelSettingsPage = createSectionPage("model", "global"); +const NotificationsSettingsPage = createSectionPage("notifications", "global"); const SystemMqttSettingsPage = createSectionPage("mqtt", "global"); const IntegrationSemanticSearchSettingsPage = createSectionPage( @@ -432,7 +432,7 @@ const settingsGroups = [ }, { label: "notifications", - items: [{ key: "notifications", component: NotificationView }], + items: [{ key: "notifications", component: NotificationsSettingsPage }], }, { label: "frigateplus", @@ -513,6 +513,7 @@ const GLOBAL_SECTION_MAPPING: Record = { audio: "globalAudioEvents", live: "globalLivePlayback", timestamp_style: "globalTimestampStyle", + notifications: "notifications", }; const ENRICHMENTS_SECTION_MAPPING: Record = { diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts index 93659d328..b1d4217e8 100644 --- a/web/src/types/configForm.ts +++ b/web/src/types/configForm.ts @@ -20,7 +20,15 @@ export type ConfigFormContext = { cameraValue?: JsonValue; overrides?: JsonValue; hasChanges?: boolean; + extraHasChanges?: boolean; + setExtraHasChanges?: (hasChanges: boolean) => void; formData?: JsonObject; + pendingDataBySection?: Record; + onPendingDataChange?: ( + sectionKey: string, + cameraName: string | undefined, + data: ConfigSectionData | null, + ) => void; baselineFormData?: JsonObject; hiddenFields?: string[]; onFormDataChange?: (data: ConfigSectionData) => void;