frigate/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx
Josh Hawkins 6fdd65ddb5
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
UI tweaks (#23346)
* remove redundant per-view toasters in settings

* add variants to standardize dialog footer button layouts

* remove text-md

this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html

* make wizard footers consistent with dialog footers

* consistent destructive button style

remove text-white from individual buttons and add it to the variant
2026-05-29 16:00:30 -06:00

850 lines
29 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 { Input } from "@/components/ui/input";
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 { 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 { isReplayCamera } from "@/utils/cameraUtil";
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<FrigateConfig>("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 && !isReplayCamera(c.name));
}, [config]);
const notificationCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter(
(conf) =>
conf.enabled_in_config &&
!isReplayCamera(conf.name) &&
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<ServiceWorkerRegistration | null>();
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<string, boolean> = {};
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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues,
});
const watchAllEnabled = form.watch("allEnabled");
const watchCameras = form.watch("cameras");
const watchEmail = form.watch("email");
const pendingCameraOverridesRef = useRef<Set<string>>(new Set());
const resetFormState = useCallback(
(values: z.infer<typeof formSchema>) => {
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 &&
!isReplayCamera(c.name) &&
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 <ActivityIndicator />;
}
if (!("Notification" in window) || !window.isSecureContext) {
return (
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<div className="w-full max-w-5xl">
<SettingsGroupCard
title={t("notification.notificationSettings.title")}
>
<div className="space-y-4">
<div className="flex 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("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</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("readTheDocumentation", { ns: "common" })}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</AlertDescription>
</Alert>
</div>
</SettingsGroupCard>
</div>
</div>
);
}
return (
<div className="flex size-full flex-col md:flex-row">
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
<div className={cn("w-full max-w-5xl space-y-6")}>
{isAdmin && (
<SettingsGroupCard
title={t("notification.notificationSettings.title")}
>
<div className="space-y-6">
<Form {...form}>
<div className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<FormLabel htmlFor="notification-email">
{t("notification.email.title")}
</FormLabel>
<FormDescription className="hidden md:block">
{t("notification.email.desc")}
</FormDescription>
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
<FormControl>
<Input
id="notification-email"
className="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 className="md:hidden">
{t("notification.email.desc")}
</FormDescription>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cameras"
render={({ field }) => (
<FormItem className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<FormLabel className="text-base">
{t("notification.cameras.title")}
</FormLabel>
<FormDescription className="hidden md:block">
{t("notification.cameras.desc")}
</FormDescription>
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
{allCameras.length > 0 ? (
<div className="w-full space-y-2 rounded-lg bg-secondary p-4">
<FormField
control={form.control}
name="allEnabled"
render={({ field: allEnabledField }) => (
<FilterSwitch
label={t("cameras.all.title", {
ns: "components/filter",
})}
isChecked={allEnabledField.value}
onCheckedChange={(checked) => {
setCameraSelectionTouched(true);
if (checked) {
form.setValue("cameras", []);
}
allEnabledField.onChange(checked);
}}
/>
)}
/>
{allCameras.map((camera) => {
const currentCameras = Array.isArray(
field.value,
)
? field.value
: [];
return (
<FilterSwitch
key={camera.name}
label={camera.name}
type="camera"
isChecked={currentCameras.includes(
camera.name,
)}
onCheckedChange={(checked) => {
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);
}}
/>
);
})}
</div>
) : (
<div className="font-normal text-destructive">
{t("notification.cameras.noCameras")}
</div>
)}
<FormDescription className="md:hidden">
{t("notification.cameras.desc")}
</FormDescription>
<FormMessage />
</div>
</FormItem>
)}
/>
</div>
</Form>
</div>
</SettingsGroupCard>
)}
<div className="space-y-6">
<SettingsGroupCard title={t("notification.deviceSpecific")}>
<div className={cn("space-y-2", isAdmin && "md:max-w-[50%]")}>
<Button
aria-label={t("notification.registerDevice")}
className="w-full md:w-auto"
disabled={!shouldFetchPubKey || publicKey == undefined}
onClick={() => {
if (registration == null) {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
navigator.serviceWorker
.register(NOTIFICATION_SERVICE_WORKER, {
updateViaCache: "none",
})
.then((workerRegistration) => {
setRegistration(workerRegistration);
if (workerRegistration.active) {
subscribeToNotifications(workerRegistration);
} else {
setTimeout(
() =>
subscribeToNotifications(
workerRegistration,
),
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>
{isAdmin && registration != null && registration.active && (
<Button
className="w-full md:w-auto"
aria-label={t("notification.sendTestNotification")}
onClick={() => sendTestNotification("notification_test")}
>
{t("notification.sendTestNotification")}
</Button>
)}
</div>
</SettingsGroupCard>
{isAdmin && notificationCameras.length > 0 && (
<SettingsGroupCard title={t("notification.globalSettings.title")}>
<div className="space-y-4">
<div className="flex max-w-xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.globalSettings.desc")}</p>
</div>
<div className="w-full rounded-lg bg-secondary p-5 md:max-w-2xl">
<div className="grid gap-6">
{notificationCameras.map((item) => (
<CameraNotificationSwitch
key={item.name}
config={config}
camera={item.name}
/>
))}
</div>
</div>
</div>
</SettingsGroupCard>
)}
</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 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 (
<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">
<CameraNameLabel
className="cursor-pointer text-primary smart-capitalize"
htmlFor="camera"
camera={camera}
/>
{!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>
);
}