Files
frigate/web/src/views/settings/NotificationsSettingsView.tsx
T
Josh Hawkins b6b3178e3d Fixes (#19406)
* Fix api filter hook

cameras, labels, sub labels, plates, and zones could be parsed as numeric values rather than strings, which would break the explore filter. This change adds an optional param to the useApiFilterArgs hook to always parse keys as string[]

* fix notifications register button from being incorrectly disabled
2025-08-06 22:09:43 -05:00

751 lines
26 KiB
TypeScript

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<React.SetStateAction<boolean>>;
};
export default function NotificationView({
setUnsavedChanges,
}: NotificationsSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } = useSWR<FrigateConfig>(
"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<ServiceWorkerRegistration | null>();
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<z.infer<typeof formSchema>>({
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"}&notifications.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<typeof formSchema>) {
setIsLoading(true);
saveToConfig(values as NotificationSettingsValueType);
}
useEffect(() => {
document.title = t("documentTitle.notifications");
}, [t]);
if (!("Notification" in window) || !window.isSecureContext) {
return (
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-1">
<Heading as="h3" className="my-2">
{t("notification.notificationSettings.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.notificationSettings.desc")}</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/notifications")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("notification.notificationSettings.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>
{t("notification.notificationUnavailable.title")}
</AlertTitle>
<AlertDescription>
<Trans ns="views/settings">
notification.notificationUnavailable.desc
</Trans>
<div className="mt-3 flex items-center">
<Link
to={getLocaleDocUrl("configuration/authentication")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("notification.notificationUnavailable.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-1">
<Heading as="h3" className="my-2">
{t("notification.notificationSettings.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.notificationSettings.desc")}</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/notifications")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("notification.notificationSettings.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("notification.email.title")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
placeholder={t("notification.email.placeholder")}
{...field}
/>
</FormControl>
<FormDescription>
{t("notification.email.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cameras"
render={({ field }) => (
<FormItem>
{allCameras && allCameras?.length > 0 ? (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
{t("notification.cameras.title")}
</FormLabel>
</div>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
<FormField
control={form.control}
name="allEnabled"
render={({ field }) => (
<FilterSwitch
label={t("cameras.all.title", {
ns: "components/filter",
})}
isChecked={field.value}
onCheckedChange={(checked) => {
setChangedValue(true);
if (checked) {
form.setValue("cameras", []);
}
field.onChange(checked);
}}
/>
)}
/>
{allCameras?.map((camera) => (
<FilterSwitch
key={camera.name}
label={camera.name.replaceAll("_", " ")}
isChecked={field.value?.includes(camera.name)}
onCheckedChange={(checked) => {
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);
}}
/>
))}
</div>
</>
) : (
<div className="font-normal text-destructive">
{t("notification.cameras.noCameras")}
</div>
)}
<FormMessage />
<FormDescription>
{t("notification.cameras.desc")}
</FormDescription>
</FormItem>
)}
/>
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</form>
</Form>
</div>
<div className="col-span-1">
<div className="mt-4 gap-2 space-y-6">
<div className="flex flex-col gap-2 md:max-w-[50%]">
<Separator className="my-2 flex bg-secondary md:hidden" />
<Heading as="h4" className="my-2">
{t("notification.deviceSpecific")}
</Heading>
<Button
aria-label={t("notification.registerDevice")}
disabled={
(!config?.notifications.enabled &&
notificationCameras.length === 0 &&
!form.watch("allEnabled") &&
form.watch("cameras").length === 0) ||
publicKey == undefined
}
onClick={() => {
if (registration == null) {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
navigator.serviceWorker
.register(NOTIFICATION_SERVICE_WORKER)
.then((registration) => {
setRegistration(registration);
if (registration.active) {
subscribeToNotifications(registration);
} else {
setTimeout(
() =>
subscribeToNotifications(registration),
1000,
);
}
});
}
});
} else {
registration.pushManager
.getSubscription()
.then((pushSubscription) => {
pushSubscription?.unsubscribe();
registration.unregister();
setRegistration(null);
removeMessage(
"notification_settings",
"registration",
);
});
}
}}
>
{registration != null
? t("notification.unregisterDevice")
: t("notification.registerDevice")}
</Button>
{registration != null && registration.active && (
<Button
aria-label={t("notification.sendTestNotification")}
onClick={() => sendTestNotification("notification_test")}
>
{t("notification.sendTestNotification")}
</Button>
)}
</div>
</div>
{notificationCameras.length > 0 && (
<div className="mt-4 gap-2 space-y-6">
<div className="space-y-3">
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("notification.globalSettings.title")}
</Heading>
<div className="max-w-xl">
<div className="mb-5 mt-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.globalSettings.desc")}</p>
</div>
</div>
<div className="flex max-w-2xl flex-col gap-2.5">
<div className="rounded-lg bg-secondary p-5">
<div className="grid gap-6">
{notificationCameras.map((item) => (
<CameraNotificationSwitch
config={config}
camera={item.name}
/>
))}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</>
);
}
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<boolean>(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 (
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-row items-center justify-start gap-3">
{!isSuspended ? (
<LuCheck className="size-6 text-success" />
) : (
<LuX className="size-6 text-danger" />
)}
<div className="flex flex-col">
<Label
className="text-md cursor-pointer text-primary smart-capitalize"
htmlFor="camera"
>
{camera.replaceAll("_", " ")}
</Label>
{!isSuspended ? (
<div className="flex flex-row items-center gap-2 text-sm text-success">
{t("notification.active")}
</div>
) : (
<div className="flex flex-row items-center gap-2 text-sm text-danger">
{t("notification.suspended", {
time: formatSuspendedUntil(notificationSuspendUntil),
})}
</div>
)}
</div>
</div>
</div>
{!isSuspended ? (
<Select onValueChange={handleSuspend}>
<SelectTrigger className="w-auto">
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">
{t("notification.suspendTime.5minutes")}
</SelectItem>
<SelectItem value="10">
{t("notification.suspendTime.10minutes")}
</SelectItem>
<SelectItem value="30">
{t("notification.suspendTime.30minutes")}
</SelectItem>
<SelectItem value="60">
{t("notification.suspendTime.1hour")}
</SelectItem>
<SelectItem value="840">
{t("notification.suspendTime.12hours")}
</SelectItem>
<SelectItem value="1440">
{t("notification.suspendTime.24hours")}
</SelectItem>
<SelectItem value="off">
{t("notification.suspendTime.untilRestart")}
</SelectItem>
</SelectContent>
</Select>
) : (
<Button
variant="destructive"
size="sm"
onClick={handleCancelSuspension}
>
{t("notification.cancelSuspension")}
</Button>
)}
</div>
);
}