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:
Nicolas Mowen 2024-07-22 14:39:15 -06:00
parent 331c882af2
commit 690ee3dc15
18 changed files with 795 additions and 14 deletions

View File

@ -36,4 +36,7 @@ chromadb == 0.5.0
# Generative AI
google-generativeai == 0.6.*
ollama == 0.2.*
openai == 1.30.*
openai == 1.30.*
# push notifications
py-vapid == 1.9.*
pywebpush == 2.0.*

View File

@ -0,0 +1,42 @@
---
id: notifications
title: Notifications
---
# Notifications
Frigate offers native notifications using the [WebPush Protocol](https://web.dev/articles/push-notifications-web-push-protocol) which uses the [VAPID spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid) to deliver notifications to web apps using encryption.
## Setting up Notifications
In order to use notifications the following requirements must be met:
- Frigate must be accessed via a secure https connection
- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported.
- In order for notifications to be usable externally, Frigate must be accessible externally
### Configuration
To configure notifications, go to the Frigate WebUI -> Settings -> Notifications and enable, then fill out the fields and save.
### Registration
Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
## Supported Notifications
Currently notifications are only supported for review alerts. More notifications will be supported in the future.
:::note
Currently, only Chrome supports images in notifications. Safari and Firefox will only show a title and message in the notification.
:::
## Reduce Notification Latency
Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery.
### Android
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.

View File

@ -372,6 +372,14 @@ motion:
# Optional: Delay when updating camera motion through MQTT from ON -> OFF (default: shown below).
mqtt_off_delay: 30
# Optional: Notification Configuration
notifications:
# Optional: Enable notification service (default: shown below)
enabled: False
# Optional: Email for push service to reach out to
# NOTE: This is required to use notifications
email: "admin@example.com"
# Optional: Record configuration
# NOTE: Can be overridden at the camera level
record:
@ -642,8 +650,8 @@ cameras:
user: admin
# Optional: password for login.
password: admin
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
ignore_time_mismatch: False
# Optional: PTZ camera object autotracking. Keeps a moving object in
# the center of the frame by automatically moving the PTZ camera.

View File

@ -54,6 +54,7 @@ module.exports = {
],
"Extra Configuration": [
"configuration/authentication",
"configuration/notifications",
"configuration/hardware_acceleration",
"configuration/ffmpeg_presets",
"configuration/tls",

View File

@ -19,6 +19,7 @@ from frigate.api.auth import AuthBp, get_jwt_secret, limiter
from frigate.api.event import EventBp
from frigate.api.export import ExportBp
from frigate.api.media import MediaBp
from frigate.api.notification import NotificationBp
from frigate.api.preview import PreviewBp
from frigate.api.review import ReviewBp
from frigate.config import FrigateConfig
@ -48,6 +49,7 @@ bp.register_blueprint(MediaBp)
bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp)
bp.register_blueprint(AuthBp)
bp.register_blueprint(NotificationBp)
def create_app(

View File

@ -0,0 +1,65 @@
"""Notification apis."""
import logging
import os
from cryptography.hazmat.primitives import serialization
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from peewee import DoesNotExist
from py_vapid import Vapid01, utils
from frigate.const import CONFIG_DIR
from frigate.models import User
logger = logging.getLogger(__name__)
NotificationBp = Blueprint("notifications", __name__)
@NotificationBp.route("/notifications/pubkey", methods=["GET"])
def get_vapid_pub_key():
if not current_app.frigate_config.notifications.enabled:
return make_response(
jsonify({"success": False, "message": "Notifications are not enabled."}),
400,
)
key = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
raw_pub = key.public_key.public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
)
return jsonify(utils.b64urlencode(raw_pub)), 200
@NotificationBp.route("/notifications/register", methods=["POST"])
def register_notifications():
if current_app.frigate_config.auth.enabled:
username = request.headers.get("remote-user", type=str) or "admin"
else:
username = "admin"
json: dict[str, any] = request.get_json(silent=True) or {}
sub = json.get("sub")
if not sub:
return jsonify(
{"success": False, "message": "Subscription must be provided."}
), 400
try:
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
User.username == username
).execute()
return make_response(
jsonify({"success": True, "message": "Successfully saved token."}), 200
)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Could not find user."}), 404
)

View File

@ -25,6 +25,7 @@ from frigate.comms.config_updater import ConfigPublisher
from frigate.comms.dispatcher import Communicator, Dispatcher
from frigate.comms.inter_process import InterProcessCommunicator
from frigate.comms.mqtt import MqttClient
from frigate.comms.webpush import WebPushClient
from frigate.comms.ws import WebSocketClient
from frigate.comms.zmq_proxy import ZmqProxy
from frigate.config import FrigateConfig
@ -401,6 +402,9 @@ class FrigateApp:
if self.config.mqtt.enabled:
comms.append(MqttClient(self.config))
if self.config.notifications.enabled:
comms.append(WebPushClient(self.config))
comms.append(WebSocketClient(self.config))
comms.append(self.inter_process_communicator)

189
frigate/comms/webpush.py Normal file
View File

@ -0,0 +1,189 @@
"""Handle sending notifications for Frigate via Firebase."""
import datetime
import json
import logging
import os
from typing import Any, Callable
from py_vapid import Vapid01
from pywebpush import WebPusher
from frigate.comms.dispatcher import Communicator
from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR
from frigate.models import User
logger = logging.getLogger(__name__)
class WebPushClient(Communicator): # type: ignore[misc]
"""Frigate wrapper for webpush client."""
def __init__(self, config: FrigateConfig) -> None:
self.config = config
self.claim_headers: dict[str, dict[str, str]] = {}
self.refresh: int = 0
self.web_pushers: dict[str, list[WebPusher]] = {}
self.expired_subs: dict[str, list[str]] = {}
if not self.config.notifications.email:
logger.warning("Email must be provided for push notifications to be sent.")
# Pull keys from PEM or generate if they do not exist
self.vapid = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
users: list[User] = (
User.select(User.username, User.notification_tokens).dicts().iterator()
)
for user in users:
self.web_pushers[user["username"]] = []
for sub in user["notification_tokens"]:
self.web_pushers[user["username"]].append(WebPusher(sub))
def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
pass
def check_registrations(self) -> None:
# check for valid claim or create new one
now = datetime.datetime.now().timestamp()
if len(self.claim_headers) == 0 or self.refresh < now:
self.refresh = int(
(datetime.datetime.now() + datetime.timedelta(hours=1)).timestamp()
)
endpoints: set[str] = set()
# get a unique set of push endpoints
for pushers in self.web_pushers.values():
for push in pushers:
endpoint: str = push.subscription_info["endpoint"]
endpoints.add(endpoint[0 : endpoint.index("/", 10)])
# create new claim
for endpoint in endpoints:
claim = {
"sub": f"mailto:{self.config.notifications.email}",
"aud": endpoint,
"exp": self.refresh,
}
self.claim_headers[endpoint] = self.vapid.sign(claim)
def cleanup_registrations(self) -> None:
# delete any expired subs
if len(self.expired_subs) > 0:
for user, expired in self.expired_subs.items():
user_subs = []
# get all subscriptions, removing ones that are expired
stored_user: User = User.get_by_id(user)
for token in stored_user.notification_tokens:
if token["endpoint"] in expired:
continue
user_subs.append(token)
# overwrite the database and reset web pushers
User.update(notification_tokens=user_subs).where(
User.username == user
).execute()
self.web_pushers[user] = []
for sub in user_subs:
self.web_pushers[user].append(WebPusher(sub))
logger.info(
f"Cleaned up {len(expired)} notification subscriptions for {user}"
)
self.expired_subs = {}
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
if topic == "reviews":
self.send_alert(json.loads(payload))
def send_alert(self, payload: dict[str, any]) -> None:
if not self.config.notifications.email:
return
self.check_registrations()
# Only notify for alerts
if payload["after"]["severity"] != "alert":
return
state = payload["type"]
# Don't notify if message is an update and important fields don't have an update
if (
state == "update"
and len(payload["before"]["data"]["objects"])
== len(payload["after"]["data"]["objects"])
and len(payload["before"]["data"]["zones"])
== len(payload["after"]["data"]["zones"])
):
return
reviewId = payload["after"]["id"]
sorted_objects: set[str] = set()
for obj in payload["after"]["data"]["objects"]:
if "-verified" not in obj:
sorted_objects.add(obj)
sorted_objects.update(payload["after"]["data"]["sub_labels"])
camera: str = payload["after"]["camera"]
title = f"{', '.join(sorted_objects).replace('_', ' ').title()}{' was' if state == 'end' else ''} detected in {', '.join(payload['after']['data']['zones']).replace('_', ' ').title()}"
message = f"Detected on {camera.replace('_', ' ').title()}"
image = f'{payload["after"]["thumb_path"].replace("/media/frigate", "")}'
# if event is ongoing open to live view otherwise open to recordings view
direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}"
for user, pushers in self.web_pushers.items():
for pusher in pushers:
endpoint = pusher.subscription_info["endpoint"]
# set headers for notification behavior
headers = self.claim_headers[
endpoint[0 : endpoint.index("/", 10)]
].copy()
headers["urgency"] = "high"
ttl = 3600 if state == "end" else 0
# send message
resp = pusher.send(
headers=headers,
ttl=ttl,
data=json.dumps(
{
"title": title,
"message": message,
"direct_url": direct_url,
"image": image,
"id": reviewId,
}
),
)
if resp.status_code == 201:
pass
elif resp.status_code == 404 or resp.status_code == 410:
# subscription is not found or has been unsubscribed
if not self.expired_subs.get(user):
self.expired_subs[user] = []
self.expired_subs[user].append(pusher.subscription_info["endpoint"])
# the subscription no longer exists and should be removed
else:
logger.warning(
f"Failed to send notification to {user} :: {resp.headers}"
)
self.cleanup_registrations()
def stop(self) -> None:
pass

View File

@ -169,6 +169,11 @@ class AuthConfig(FrigateBaseModel):
hash_iterations: int = Field(default=600000, title="Password hash iterations")
class NotificationConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable notifications")
email: Optional[str] = Field(default=None, title="Email required for push.")
class StatsConfig(FrigateBaseModel):
amd_gpu_stats: bool = Field(default=True, title="Enable AMD GPU stats.")
intel_gpu_stats: bool = Field(default=True, title="Enable Intel GPU stats.")
@ -1361,6 +1366,9 @@ class FrigateConfig(FrigateBaseModel):
default_factory=dict, title="Frigate environment variables."
)
ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.")
notifications: NotificationConfig = Field(
default_factory=NotificationConfig, title="Notification Config"
)
telemetry: TelemetryConfig = Field(
default_factory=TelemetryConfig, title="Telemetry configuration."
)

View File

@ -118,3 +118,4 @@ class RecordingsToDelete(Model): # type: ignore[misc]
class User(Model): # type: ignore[misc]
username = CharField(null=False, primary_key=True, max_length=30)
password_hash = CharField(null=False, max_length=120)
notification_tokens = JSONField()

View File

@ -0,0 +1,40 @@
"""Peewee migrations
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
from playhouse.sqlite_ext import JSONField
from frigate.models import User
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.add_fields(
User,
notification_tokens=JSONField(default=[]),
)
def rollback(migrator, database, fake=False, **kwargs):
pass

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "frigate",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View 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);
}
}
}
});

View File

@ -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",

View File

@ -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

View File

@ -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;

View 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}&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>
</>
);
}