diff --git a/.gitignore b/.gitignore index 2d3ab15db..8d8018dd6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ /deps /*.ez -/priv/static/ +# /priv/static/ /rel node_modules/ diff --git a/NOTIFICATIONSETUP.md b/NOTIFICATIONSETUP.md new file mode 100644 index 000000000..b744a4bfb --- /dev/null +++ b/NOTIFICATIONSETUP.md @@ -0,0 +1,11 @@ +## Setup for push notifications server + +Clone and setup the repo: + +Elixir: +https://github.com/DhananjayPurohit/push_server + +Node.js: +https://github.com/DhananjayPurohit/push_server_node + +Add the generated public vapid key in `assets/src/config.js` diff --git a/assets/src/app.js b/assets/src/app.js index 54056e0a2..629d0fbde 100644 --- a/assets/src/app.js +++ b/assets/src/app.js @@ -7,6 +7,9 @@ import apolloClient from './apolloClient' import Root from './components/Root' import ReactGA from 'react-ga' +import * as serviceWorker from './serviceWorker' +import { subscribeUser } from './subscription' + if (ENV.GOOGLE_ANALYTICS_ID) { ReactGA.initialize(ENV.GOOGLE_ANALYTICS_ID) ReactGA.pageview(window.location.pathname + window.location.search); @@ -18,3 +21,7 @@ render( , document.getElementById('root'), ) + +serviceWorker.register() + +subscribeUser() \ No newline at end of file diff --git a/assets/src/config.js b/assets/src/config.js new file mode 100644 index 000000000..eb9c6c143 --- /dev/null +++ b/assets/src/config.js @@ -0,0 +1,5 @@ + +export default { + VAPID_KEY: "PUBLIC_VAPID_KEY", + API_URL: "http://localhost:9000" +} diff --git a/assets/src/serviceWorker.js b/assets/src/serviceWorker.js new file mode 100644 index 000000000..c273ae3b5 --- /dev/null +++ b/assets/src/serviceWorker.js @@ -0,0 +1,137 @@ +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://bit.ly/CRA-PWA + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.1/8 is considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +export function register(config) { + if ('serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swFileName = process.env.NODE_ENV === 'production' ? 'service-worker.js' : 'custom-sw.js' + const swUrl = `${publicUrl.origin}/${swFileName}`; + + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://bit.ly/CRA-PWA' + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + }); + } +} + +function registerValidSW(swUrl, config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker(swUrl, config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + registration.unregister(); + }); + } +} diff --git a/assets/src/subscription.js b/assets/src/subscription.js new file mode 100644 index 000000000..588427f79 --- /dev/null +++ b/assets/src/subscription.js @@ -0,0 +1,65 @@ +import subscription from "./config" + +const VapidKey = subscription.VAPID_KEY +const APIUrl = subscription.API_URL +const convertedVapidKey = urlBase64ToUint8Array(VapidKey) + +function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - base64String.length % 4) % 4) + // eslint-disable-next-line + 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 +} + +function sendSubscription(subscription) { + return fetch(`${APIUrl}/notifications/subscribe`, { + method: 'POST', + body: JSON.stringify(subscription), + headers: { + 'Content-Type': 'application/json' + } + }) +} + +export function subscribeUser() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(function(registration) { + if (!registration.pushManager) { + console.log('Push manager unavailable.') + return + } + + registration.pushManager.getSubscription().then(function(existedSubscription) { + if (existedSubscription === null) { + console.log('No subscription detected, make a request.') + registration.pushManager.subscribe({ + applicationServerKey: convertedVapidKey, + userVisibleOnly: true, + }).then(function(newSubscription) { + console.log('New subscription added.') + sendSubscription(newSubscription) + }).catch(function(e) { + if (Notification.permission !== 'granted') { + console.log('Permission was not granted.') + } else { + console.error('An error ocurred during the subscription process.', e) + } + }) + } else { + console.log('Existed subscription detected.') + sendSubscription(existedSubscription) + } + }) + }) + .catch(function(e) { + console.error('An error ocurred during Service Worker registration.', e) + }) + } +} diff --git a/lib/orcasite_web/endpoint.ex b/lib/orcasite_web/endpoint.ex index a2474d490..c848b6a29 100644 --- a/lib/orcasite_web/endpoint.ex +++ b/lib/orcasite_web/endpoint.ex @@ -20,7 +20,7 @@ defmodule OrcasiteWeb.Endpoint do at: "/", from: :orcasite, gzip: false, - only: ~w(css fonts images js favicon.ico robots.txt) + only: ~w(css fonts images js favicon.ico robots.txt custom-sw.js service-worker.js) ) # Code reloading can be explicitly enabled under the diff --git a/priv/static/custom-sw.js b/priv/static/custom-sw.js new file mode 100644 index 000000000..d09d59ff9 --- /dev/null +++ b/priv/static/custom-sw.js @@ -0,0 +1,10 @@ +self.addEventListener('push', event => { + const data = event.data.json() + console.log('New notification', data) + const options = { + body: data.body, + } + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +})