forked from Github/frigate
Implement support for notifications (#12523)
* Setup basic notification page * Add basic notification implementation * Register for push notifications * Implement dispatching * Add fields * Handle image and link * Add notification config * Add field for users notification tokens * Implement saving of notification tokens * Implement VAPID key generation * Implement public key encoding * Implement webpush from server * Implement push notification handling * Make notifications config only * Add maskable icon * Use zod form to control notification settings in the UI * Use js * Always open notification * Support multiple endpoints * Handle cleaning up expired notification registrations * Correctly unsubscribe notifications * Change ttl dynamically * Add note about notification latency and features * Cleanup docs * Fix firefox pushes * Add links to docs and improve formatting * Improve wording * Fix docstring Co-authored-by: Blake Blackshear <blake@frigate.video> * Handle case where native auth is not enabled * Show errors in UI --------- Co-authored-by: Blake Blackshear <blake@frigate.video>
This commit is contained in:
BIN
web/public/images/maskable-badge.png
Normal file
BIN
web/public/images/maskable-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
39
web/public/notifications-worker.js
Normal file
39
web/public/notifications-worker.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Notifications Worker
|
||||
|
||||
self.addEventListener("push", function (event) {
|
||||
// @ts-expect-error we know this exists
|
||||
if (event.data) {
|
||||
// @ts-expect-error we know this exists
|
||||
const data = event.data.json();
|
||||
// @ts-expect-error we know this exists
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.message,
|
||||
icon: "/images/maskable-icon.png",
|
||||
image: data.image,
|
||||
badge: "/images/maskable-badge.png",
|
||||
tag: data.id,
|
||||
data: { id: data.id, link: data.direct_url },
|
||||
});
|
||||
} else {
|
||||
// pass
|
||||
// This push event has no data
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
// @ts-expect-error we know this exists
|
||||
if (event.notification) {
|
||||
// @ts-expect-error we know this exists
|
||||
event.notification.close();
|
||||
|
||||
// @ts-expect-error we know this exists
|
||||
if (event.notification.data) {
|
||||
const url = event.notification.data.link;
|
||||
// eslint-disable-next-line no-undef
|
||||
if (clients.openWindow) {
|
||||
// eslint-disable-next-line no-undef
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,12 @@
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/images/maskable-badge.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
|
||||
@@ -35,24 +35,39 @@ import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||
import AuthenticationView from "@/views/settings/AuthenticationView";
|
||||
import NotificationView from "@/views/settings/NotificationsSettingsView";
|
||||
|
||||
const allSettingsViews = [
|
||||
"general",
|
||||
"camera settings",
|
||||
"masks / zones",
|
||||
"motion tuner",
|
||||
"debug",
|
||||
"users",
|
||||
"notifications",
|
||||
] as const;
|
||||
type SettingsType = (typeof allSettingsViews)[number];
|
||||
|
||||
export default function Settings() {
|
||||
const settingsViews = [
|
||||
"general",
|
||||
"camera settings",
|
||||
"masks / zones",
|
||||
"motion tuner",
|
||||
"debug",
|
||||
"users",
|
||||
] as const;
|
||||
|
||||
type SettingsType = (typeof settingsViews)[number];
|
||||
const [page, setPage] = useState<SettingsType>("general");
|
||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// 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);
|
||||
@@ -181,6 +196,9 @@ export default function Settings() {
|
||||
/>
|
||||
)}
|
||||
{page == "users" && <AuthenticationView />}
|
||||
{page == "notifications" && (
|
||||
<NotificationView setUnsavedChanges={setUnsavedChanges} />
|
||||
)}
|
||||
</div>
|
||||
{confirmationDialogOpen && (
|
||||
<AlertDialog
|
||||
|
||||
@@ -346,6 +346,11 @@ export interface FrigateConfig {
|
||||
user: string | null;
|
||||
};
|
||||
|
||||
notifications: {
|
||||
enabled: boolean;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
objects: {
|
||||
filters: {
|
||||
[objectName: string]: {
|
||||
@@ -400,7 +405,7 @@ export interface FrigateConfig {
|
||||
|
||||
semantic_search: {
|
||||
enabled: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
snapshots: {
|
||||
bounding_box: boolean;
|
||||
|
||||
344
web/src/views/settings/NotificationsSettingsView.tsx
Normal file
344
web/src/views/settings/NotificationsSettingsView.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
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 { 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, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { z } from "zod";
|
||||
|
||||
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
||||
|
||||
type NotificationSettingsValueType = {
|
||||
enabled: boolean;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
type NotificationsSettingsViewProps = {
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
export default function NotificationView({
|
||||
setUnsavedChanges,
|
||||
}: NotificationsSettingsViewProps) {
|
||||
const { data: config, mutate: updateConfig } = useSWR<FrigateConfig>(
|
||||
"config",
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// status bar
|
||||
|
||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||
|
||||
// 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",
|
||||
"Unsaved Notification Registrations",
|
||||
undefined,
|
||||
"registration",
|
||||
);
|
||||
|
||||
registration.pushManager
|
||||
.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: publicKey,
|
||||
})
|
||||
.then((pushSubscription) => {
|
||||
axios
|
||||
.post("notifications/register", {
|
||||
sub: pushSubscription,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to save notification registration.", {
|
||||
position: "top-center",
|
||||
});
|
||||
pushSubscription.unsubscribe();
|
||||
registration.unregister();
|
||||
setRegistration(null);
|
||||
});
|
||||
toast.success(
|
||||
"Successfully registered for notifications. Restart to start receiving notifications.",
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
[publicKey, addMessage],
|
||||
);
|
||||
|
||||
// notification state
|
||||
|
||||
const [registration, setRegistration] =
|
||||
useState<ServiceWorkerRegistration | null>();
|
||||
|
||||
useEffect(() => {
|
||||
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({
|
||||
enabled: z.boolean(),
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
enabled: config?.notifications.enabled,
|
||||
email: config?.notifications.email,
|
||||
},
|
||||
});
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUnsavedChanges(false);
|
||||
form.reset({
|
||||
enabled: config.notifications.enabled,
|
||||
email: config.notifications.email || "",
|
||||
});
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config, removeMessage, setUnsavedChanges]);
|
||||
|
||||
const saveToConfig = useCallback(
|
||||
async (
|
||||
{ enabled, email }: NotificationSettingsValueType, // values submitted via the form
|
||||
) => {
|
||||
axios
|
||||
.put(
|
||||
`config/set?notifications.enabled=${enabled}¬ifications.email=${email}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
},
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success("Notification settings have been saved.", {
|
||||
position: "top-center",
|
||||
});
|
||||
updateConfig();
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to save config changes: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[updateConfig, setIsLoading],
|
||||
);
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true);
|
||||
saveToConfig(values as NotificationSettingsValueType);
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="mt-2 space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex flex-row items-center justify-start gap-2">
|
||||
<Label className="cursor-pointer" htmlFor="auto-live">
|
||||
Notifications
|
||||
</Label>
|
||||
<Switch
|
||||
id="auto-live"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
return field.onChange(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
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="example@email.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Entering a valid email is required, as this is used by the
|
||||
push server in case problems occur.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="mt-4 space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<Button
|
||||
disabled={
|
||||
!config?.notifications.enabled || 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 ? "Unregister" : "Register"} for notifications on this device`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user