mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-05 05:10:33 +03:00
* miscellaneous fixes - keep base path in recording share links - reject non-positive scale on recordings/unavailable, which looped forever on the event loop - reject negative vod event padding, which cleared has_clip - clamp negative preview gif/mp4 offsets - don't delete exports that are still in progress - hash on unknown login usernames so timing doesn't reveal valid users - include the first minute of the events time-of-day filter - show an error when the push subscription is refused - don't wipe an unnamed known plates entry when Add is pressed again - use elapsed hours for export last N hours so DST doesn't shift the range * fix live audio settings and trigger highlights - start each camera group's audio from that group's saved settings instead of merging every group - don't clear the previous trigger's highlight timeout when another trigger fires * address review feedback - Discard the export row when an export fails before or during ffmpeg, so it doesn't stay in progress until a restart - Skip the empty thumbnail path when discarding a failed export - Measure preview clip duration from the clamped start - Default live player volume to 1 when the group has no saved setting - Clear the unsaved registration message when notification registration fails - Keep a highlight timer per trigger - Remove the severity order_by in /review, which the start_time order_by replaced * Hold pandas 3.1 until numpy 2 * fix two-way talk shortcut and detail stream active item after camera switch * show already running message when a media sync is in progress * pick the earliest overlapping preview for preview gif and mp4 * unmute camera audio when starting two-way talk from the shortcut * remove password strength meter and fix confirm password feedback The strength meter still scored against the old 8 character and character class rules, so any password with a digit, like test1234, showed a full green "Very Strong" bar next to the failing 12 character requirement. The meter is gone and the length checklist is the only feedback. The password field's form message duplicated the checklist, and editing the password never revalidated the confirm field, so a fixed mismatch kept showing "Passwords do not match". Editing the password now revalidates the confirm field once it has a value, and both dialogs show a live match/mismatch indicator in place of the confirm form message.
855 lines
29 KiB
TypeScript
855 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-context";
|
|
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 { ConfigFieldMessage } from "../ConfigFieldMessage";
|
|
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 { useTranslation } from "react-i18next";
|
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
|
import { isPWA } from "@/utils/isPWA";
|
|
import { isIOS } from "react-device-detect";
|
|
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", "ttl"],
|
|
);
|
|
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,
|
|
})
|
|
.then(() => {
|
|
toast.success(t("notification.toast.success.registered"), {
|
|
position: "top-center",
|
|
});
|
|
})
|
|
.catch(() => {
|
|
toast.error(t("notification.toast.error.registerFailed"), {
|
|
position: "top-center",
|
|
});
|
|
pushSubscription.unsubscribe();
|
|
workerRegistration.unregister();
|
|
setRegistration(null);
|
|
removeMessage("notification_settings", "registration");
|
|
});
|
|
})
|
|
.catch(() => {
|
|
toast.error(t("notification.toast.error.registerFailed"), {
|
|
position: "top-center",
|
|
});
|
|
workerRegistration.unregister();
|
|
setRegistration(null);
|
|
removeMessage("notification_settings", "registration");
|
|
});
|
|
},
|
|
[addMessage, removeMessage, 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) {
|
|
// iOS only exposes web push to apps installed to the Home Screen, so a
|
|
// secure-context iOS browser tab that isn't an installed PWA has no
|
|
// Notification API. Android supports web push in a normal tab, so it never
|
|
// reaches this case and keeps the generic secure-context message.
|
|
const requiresPwaInstall = isIOS && window.isSecureContext && !isPWA;
|
|
|
|
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>
|
|
|
|
<ConfigFieldMessage
|
|
messageKey={
|
|
requiresPwaInstall
|
|
? "notification.notificationUnavailable.descPwa"
|
|
: "notification.notificationUnavailable.desc"
|
|
}
|
|
severity="warning"
|
|
docLink={
|
|
requiresPwaInstall
|
|
? "configuration/notifications"
|
|
: "configuration/authentication"
|
|
}
|
|
/>
|
|
</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 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={
|
|
registration != null
|
|
? t("notification.unregisterDevice")
|
|
: 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 =
|
|
notificationSuspendUntil !== "0" || notificationState === "OFF";
|
|
|
|
const handleSuspend = (duration: string) => {
|
|
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>
|
|
);
|
|
}
|