mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-30 23:59:02 +03:00
Improve notifications (#16632)
* add notification cooldown * cooldown docs * show alert box when notifications are used in an insecure context * add ability to suspend notifications from dashboard context menu
This commit is contained in:
@@ -11,6 +11,9 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
@@ -24,12 +27,19 @@ import { VolumeSlider } from "@/components/ui/slider";
|
||||
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
FrigateConfig,
|
||||
GroupStreamingSettings,
|
||||
} from "@/types/frigateConfig";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import {
|
||||
IoIosNotifications,
|
||||
IoIosNotificationsOff,
|
||||
IoIosWarning,
|
||||
} from "react-icons/io";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { useNotifications, useNotificationSuspend } from "@/api/ws";
|
||||
|
||||
type LiveContextMenuProps = {
|
||||
className?: string;
|
||||
@@ -48,6 +58,7 @@ type LiveContextMenuProps = {
|
||||
statsState: boolean;
|
||||
toggleStats: () => void;
|
||||
resetPreferredLiveMode: () => void;
|
||||
config?: FrigateConfig;
|
||||
children?: ReactNode;
|
||||
};
|
||||
export default function LiveContextMenu({
|
||||
@@ -67,6 +78,7 @@ export default function LiveContextMenu({
|
||||
statsState,
|
||||
toggleStats,
|
||||
resetPreferredLiveMode,
|
||||
config,
|
||||
children,
|
||||
}: LiveContextMenuProps) {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
@@ -185,6 +197,44 @@ export default function LiveContextMenu({
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// notifications
|
||||
|
||||
const notificationsEnabledInConfig =
|
||||
config?.cameras[camera].notifications.enabled_in_config;
|
||||
|
||||
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) => {
|
||||
if (duration === "off") {
|
||||
sendNotification("OFF");
|
||||
} else {
|
||||
sendNotificationSuspend(Number.parseInt(duration));
|
||||
}
|
||||
};
|
||||
|
||||
const formatSuspendedUntil = (timestamp: string) => {
|
||||
if (timestamp === "0") return "Frigate restarts.";
|
||||
|
||||
return formatUnixTimestampToDateTime(Number.parseInt(timestamp), {
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
timezone: config?.ui.timezone,
|
||||
strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
|
||||
@@ -288,6 +338,115 @@ export default function LiveContextMenu({
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
{notificationsEnabledInConfig && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Notifications</span>
|
||||
</div>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<div className="flex flex-col gap-0.5 px-2 py-1.5 text-sm font-medium">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{notificationState === "ON" ? (
|
||||
<>
|
||||
{isSuspended ? (
|
||||
<>
|
||||
<IoIosNotificationsOff className="size-5 text-muted-foreground" />
|
||||
<span>Suspended</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IoIosNotifications className="size-5 text-muted-foreground" />
|
||||
<span>Enabled</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IoIosNotificationsOff className="size-5 text-danger" />
|
||||
<span>Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isSuspended && (
|
||||
<span className="text-xs text-primary-variant">
|
||||
Until {formatSuspendedUntil(notificationSuspendUntil)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSuspended ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
sendNotification("ON");
|
||||
sendNotificationSuspend(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{notificationState === "ON" ? (
|
||||
<span>Unsuspend</span>
|
||||
) : (
|
||||
<span>Enable</span>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
) : (
|
||||
notificationState === "ON" && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
Suspend for:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<ContextMenuItem onClick={() => handleSuspend("5")}>
|
||||
5 minutes
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleSuspend("10")}
|
||||
>
|
||||
10 minutes
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleSuspend("30")}
|
||||
>
|
||||
30 minutes
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleSuspend("60")}
|
||||
>
|
||||
1 hour
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleSuspend("840")}
|
||||
>
|
||||
12 hours
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleSuspend("1440")}
|
||||
>
|
||||
24 hours
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleSuspend("off")}
|
||||
>
|
||||
Until restart
|
||||
</ContextMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -61,19 +61,6 @@ export default function Settings() {
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// available settings views
|
||||
|
||||
const settingsViews = useMemo(() => {
|
||||
const views = [...allSettingsViews];
|
||||
|
||||
if (!("Notification" in window) || !window.isSecureContext) {
|
||||
const index = views.indexOf("notifications");
|
||||
views.splice(index, 1);
|
||||
}
|
||||
|
||||
return views;
|
||||
}, []);
|
||||
|
||||
// TODO: confirm leave page
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
@@ -160,7 +147,7 @@ export default function Settings() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.values(settingsViews).map((item) => (
|
||||
{Object.values(allSettingsViews).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
|
||||
@@ -584,6 +584,7 @@ export default function DraggableGridLayout({
|
||||
resetPreferredLiveMode={() =>
|
||||
resetPreferredLiveMode(camera.name)
|
||||
}
|
||||
config={config}
|
||||
>
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
@@ -790,6 +791,7 @@ type GridLiveContextMenuProps = {
|
||||
muteAll: () => void;
|
||||
unmuteAll: () => void;
|
||||
resetPreferredLiveMode: () => void;
|
||||
config?: FrigateConfig;
|
||||
};
|
||||
|
||||
const GridLiveContextMenu = React.forwardRef<
|
||||
@@ -819,6 +821,7 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
muteAll,
|
||||
unmuteAll,
|
||||
resetPreferredLiveMode,
|
||||
config,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -849,6 +852,7 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
muteAll={muteAll}
|
||||
unmuteAll={unmuteAll}
|
||||
resetPreferredLiveMode={resetPreferredLiveMode}
|
||||
config={config}
|
||||
>
|
||||
{children}
|
||||
</LiveContextMenu>
|
||||
|
||||
@@ -507,6 +507,7 @@ export default function LiveDashboardView({
|
||||
resetPreferredLiveMode={() =>
|
||||
resetPreferredLiveMode(camera.name)
|
||||
}
|
||||
config={config}
|
||||
>
|
||||
<LivePlayer
|
||||
cameraRef={cameraRef}
|
||||
|
||||
@@ -20,7 +20,7 @@ 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 { LuAlertCircle, LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
||||
|
||||
@@ -161,6 +162,9 @@ export default function NotificationView({
|
||||
useState<ServiceWorkerRegistration | null>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!("Notification" in window) || !window.isSecureContext) {
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker
|
||||
.getRegistration(NOTIFICATION_SERVICE_WORKER)
|
||||
.then((worker) => {
|
||||
@@ -279,6 +283,60 @@ export default function NotificationView({
|
||||
saveToConfig(values as NotificationSettingsValueType);
|
||||
}
|
||||
|
||||
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">
|
||||
Notification Settings
|
||||
</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>
|
||||
Frigate can natively send push notifications to your device
|
||||
when it is running in the browser or installed as a PWA.
|
||||
</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/notifications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the Documentation{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<LuAlertCircle className="size-5" />
|
||||
<AlertTitle>Notifications Unavailable</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
Web push notifications require a secure context (
|
||||
<code>https://...</code>). This is a browser limitation. Access
|
||||
Frigate securely to use notifications.
|
||||
<div className="mt-3 flex items-center">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/authentication"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the 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">
|
||||
|
||||
Reference in New Issue
Block a user