diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index e58f292a4..d811d523d 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -29,10 +29,29 @@ import { AppProps } from 'next/app'; import { Router, useRouter } from 'next/router'; import { RecoilRoot } from 'recoil'; +import { useEffect } from 'react'; import AdminLayout from '../components/layouts/admin-layout'; import SimpleLayout from '../components/layouts/SimpleLayout'; function App({ Component, pageProps }: AppProps) { + useEffect(() => { + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/serviceWorker.js').then( + registration => { + console.debug( + 'Service Worker registration successful with scope: ', + registration.scope, + ); + }, + err => { + console.error('Service Worker registration failed: ', err); + }, + ); + }); + } + }, []); + const router = useRouter() as Router; if (router.pathname.startsWith('/admin')) { return ; diff --git a/web/public/serviceWorker.js b/web/public/serviceWorker.js new file mode 100644 index 000000000..cf7cc1890 --- /dev/null +++ b/web/public/serviceWorker.js @@ -0,0 +1,25 @@ +/* eslint-disable no-restricted-globals */ +self.addEventListener('activate', event => { + console.log('Owncast service worker activated', event); +}); + +self.addEventListener('install', event => { + console.log('installing Owncast service worker...', event); +}); + +self.addEventListener('push', event => { + const data = JSON.parse(event.data.text()); + const { title, body, icon, tag } = data; + const options = { + title: title || 'Live!', + body: body || 'This live stream has started.', + icon: icon || '/logo/external', + tag, + }; + + event.waitUntil(self.registration.showNotification(options.title, options)); +}); + +self.addEventListener('notificationclick', event => { + clients.openWindow('/'); +}); diff --git a/web/services/notifications-service.ts b/web/services/notifications-service.ts new file mode 100644 index 000000000..58e15bdc5 --- /dev/null +++ b/web/services/notifications-service.ts @@ -0,0 +1,48 @@ +export async function saveNotificationRegistration(channel, destination, accessToken) { + const URL_REGISTER_NOTIFICATION = `/api/notifications/register`; + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ channel, destination }), + }; + + try { + await fetch(`${URL_REGISTER_NOTIFICATION}?accessToken=${accessToken}`, options); + } catch (e) { + console.error(e); + } +} + +export function isPushNotificationSupported() { + return 'serviceWorker' in navigator && 'PushManager' in window; +} + +function urlBase64ToUint8Array(base64String: string) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +export async function registerWebPushNotifications(vapidPublicKey) { + const registration = await navigator.serviceWorker.ready; + let subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }); + } + + return JSON.stringify(subscription); +}