diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index f27e0c7ab..3ac7c9fee 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -440,7 +440,7 @@ export function useNotifications(camera: string): { export function useNotificationSuspend(camera: string): { payload: string; - send: (payload: string, retain?: boolean) => void; + send: (payload: number, retain?: boolean) => void; } { const { value: { payload }, diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index 83b2768c3..31000affa 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -14,14 +14,13 @@ 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 { Switch } from "@/components/ui/switch"; 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 { LuExternalLink } from "react-icons/lu"; +import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import useSWR from "swr"; @@ -39,12 +38,14 @@ import { SelectItem, } from "@/components/ui/select"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import FilterSwitch from "@/components/filter/FilterSwitch"; const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js"; type NotificationSettingsValueType = { - enabled: boolean; + allEnabled: boolean; email?: string; + cameras: string[]; }; type NotificationsSettingsViewProps = { @@ -60,13 +61,28 @@ export default function NotificationView({ }, ); - const cameras = useMemo(() => { + 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 && conf.notifications.enabled_in_config) + .filter( + (conf) => + conf.enabled && + conf.notifications && + conf.notifications.enabled_in_config, + ) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); @@ -75,6 +91,22 @@ export default function NotificationView({ // 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 @@ -147,28 +179,44 @@ export default function NotificationView({ const [isLoading, setIsLoading] = useState(false); const formSchema = z.object({ - enabled: z.boolean(), + allEnabled: z.boolean(), email: z.string(), + cameras: z.array(z.string()), }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { - enabled: config?.notifications.enabled, + 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({ - enabled: config.notifications.enabled, + 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 @@ -176,11 +224,27 @@ export default function NotificationView({ const saveToConfig = useCallback( async ( - { enabled, email }: NotificationSettingsValueType, // values submitted via the form + { 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=${enabled}¬ifications.email=${email}`, + `config/set?notifications.enabled=${allEnabled ? "True" : "False"}¬ifications.email=${email}${allCameraQueries}`, { requires_restart: 0, }, @@ -207,7 +271,7 @@ export default function NotificationView({ setIsLoading(false); }); }, - [updateConfig, setIsLoading], + [updateConfig, setIsLoading, allCameras], ); function onSubmit(values: z.infer) { @@ -247,30 +311,8 @@ export default function NotificationView({
- ( - - -
- - { - return field.onChange(checked); - }} - /> -
-
-
- )} - /> )} /> -
+ + ( + + {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. + +
+ )} + /> + +
- {cameras && ( + {notificationCameras.length > 0 && (
- Cameras + Suspend Notifications -
-
-

Enable / disable notifications for specific cameras.

+
+
+

+ Temporarily suspend notifications for specific cameras on + all registered devices. +

-
- {cameras.map((item) => ( - - ))} +
+
+
+ {notificationCameras.map((item) => ( + + ))} +
+
@@ -420,21 +539,24 @@ export function CameraNotificationSwitch({ useEffect(() => { if (notificationSuspendUntil) { - setIsSuspended(notificationSuspendUntil != "0"); + setIsSuspended( + notificationSuspendUntil !== "0" || notificationState === "OFF", + ); } - }, [notificationSuspendUntil]); + }, [notificationSuspendUntil, notificationState]); const handleSuspend = (duration: string) => { + setIsSuspended(true); if (duration == "off") { sendNotification("OFF"); - setIsSuspended(true); } else { - sendNotificationSuspend(duration); + sendNotificationSuspend(parseInt(duration)); } }; const handleCancelSuspension = () => { - sendNotificationSuspend("0"); + sendNotification("ON"); + sendNotificationSuspend(0); }; const formatSuspendedUntil = (timestamp: string) => { @@ -448,48 +570,60 @@ export function CameraNotificationSwitch({ }); }; - const isOn = notificationState === "ON" || isSuspended; - return ( -
-
- +
+
+
+ {!isSuspended ? ( + + ) : ( + + )} +
+ - {!isSuspended && isOn && ( - - )} - {isSuspended && ( - - )} -
- {isSuspended && notificationSuspendUntil && ( -
- Suspended until {formatSuspendedUntil(notificationSuspendUntil)} + {!isSuspended ? ( +
+ Notifications Active +
+ ) : ( +
+ Notifications suspended until{" "} + {formatSuspendedUntil(notificationSuspendUntil)} +
+ )} +
+
+ + {!isSuspended ? ( + + ) : ( + )}
);