blakeblackshear.frigate/web/src/views/settings/NotificationsSettingsView.tsx
Nicolas Mowen 690ee3dc15 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>
2024-08-29 20:19:50 -06:00

345 lines
11 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 { 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}&notifications.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>
</>
);
}