diff --git a/ui/package-lock.json b/ui/package-lock.json index f25738f5c..88cf30f78 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -17199,6 +17199,14 @@ "workbox-webpack-plugin": "3.6.3" } }, + "react-switch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-switch/-/react-switch-5.0.1.tgz", + "integrity": "sha512-Pa5kvqRfX85QUCK1Jv0rxyeElbC3aNpCP5hV0LoJpU/Y6kydf0t4kRriQ6ZYA4kxWwAYk/cH51T4/sPzV9mCgQ==", + "requires": { + "prop-types": "^15.6.2" + } + }, "react-transition-group": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-1.2.1.tgz", diff --git a/ui/package.json b/ui/package.json index 3e0919f08..c87d6f5c2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,6 +29,7 @@ "react-redux": "^6.0.1", "react-router-dom": "^4.3.1", "react-scripts": "^2.1.8", + "react-switch": "^5.0.1", "react-use-form-state": "^0.9.1", "redux": "^4.0.4", "redux-devtools-extension": "^2.13.8", diff --git a/ui/src/components/Box.module.css b/ui/src/components/Box.module.css index 3a32aa880..85e6c342a 100644 --- a/ui/src/components/Box.module.css +++ b/ui/src/components/Box.module.css @@ -2,6 +2,7 @@ .box { background-color: #c4c4c461; padding: 1em; + margin-bottom: 1em; } .box > legend { diff --git a/ui/src/helpers/index.ts b/ui/src/helpers/index.ts index 2fe32a3dd..2c5eb07bb 100644 --- a/ui/src/helpers/index.ts +++ b/ui/src/helpers/index.ts @@ -9,3 +9,4 @@ export * from './isValidForm' export * from './matchResponse' export * from './matchState' export * from './regexp' +export * from './notification' diff --git a/ui/src/helpers/notification.ts b/ui/src/helpers/notification.ts new file mode 100644 index 000000000..20da622eb --- /dev/null +++ b/ui/src/helpers/notification.ts @@ -0,0 +1,44 @@ +import { API_BASE_URL } from '../constants' + +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) + return Uint8Array.from(Array.from(rawData).map(char => char.charCodeAt(0))) +} + +export const isNotificationSupported = () => 'Notification' in window +export const isNotificationGranted = () => isNotificationSupported() && Notification.permission == 'granted' + +export const unSubscribePush = async (registration: ServiceWorkerRegistration) => { + let subscription = await registration.pushManager.getSubscription() + if (subscription) { + const ok = await subscription.unsubscribe() + if (!ok) { + throw new Error('Unable to renew push manager subscription.') + } + return + } +} + +export const subscribePush = async (registration: ServiceWorkerRegistration) => { + let applicationServerKey: Uint8Array | undefined + try { + let subscription = await registration.pushManager.getSubscription() + if (!subscription) { + // No subscription: creat a new one + const res = await fetch(API_BASE_URL) + const data = await res.json() + applicationServerKey = urlBase64ToUint8Array(data.vapid) + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey + }) + } + console.log('Subscribed to push manager:', subscription.endpoint) + return subscription + } catch (err) { + console.error('Error when creating push subscription:', err) + throw err + } +} diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 76c5832d8..6c573a176 100755 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -8,7 +8,6 @@ import App from './App' import authService from './auth' import configureStore from './configureStore' import { getOnlineStatus } from './helpers' -import { setupNotification } from './notification' import * as serviceWorker from './serviceWorker' const run = () => { @@ -17,7 +16,6 @@ const run = () => { const store = configureStore(history, initialState) ReactDOM.render(, document.getElementById('root')) serviceWorker.register() - setupNotification() localStorage.setItem('last_run', new Date().toISOString()) } diff --git a/ui/src/notification.ts b/ui/src/notification.ts deleted file mode 100644 index 0e46e55de..000000000 --- a/ui/src/notification.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { API_BASE_URL } from './constants' - -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) - return Uint8Array.from(Array.from(rawData).map(char => char.charCodeAt(0))) -} - -export const setupNotification = () => { - if (!('Notification' in window)) { - console.error('This browser does not support desktop notification') - } else if (Notification.permission === 'granted') { - console.log('Permission to receive notifications has been granted') - } else if (Notification.permission !== 'denied') { - Notification.requestPermission(function(permission) { - if (permission === 'granted') { - console.log('Permission to receive notifications has been granted') - } - }) - } -} - -const renewPushSubscription = async (registration: ServiceWorkerRegistration, applicationServerKey: Uint8Array) => { - try { - let subscription = await registration.pushManager.getSubscription() - if (subscription) { - const ok = await subscription.unsubscribe() - if (ok) { - console.log('Un-subscribed with success. Subscribe again...') - subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey - }) - console.log('Subscribed to push manager:', subscription.endpoint) - } - } - } catch (err) { - console.error(err) - } -} - -export const subscribePush = async (registration: ServiceWorkerRegistration) => { - let applicationServerKey: Uint8Array | undefined - try { - let subscription = await registration.pushManager.getSubscription() - if (!subscription) { - const res = await fetch(API_BASE_URL) - const data = await res.json() - applicationServerKey = urlBase64ToUint8Array(data.vapid) - subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey - }) - console.log('Subscribed to push manager:', subscription.endpoint) - } else { - console.log('Push subscription:', subscription.endpoint) - } - } catch (err) { - console.error(err) - if (applicationServerKey) { - // Try to renew subscription if allready subscribed - // with a different applicationServerKey - console.log('Renewing push subscription...') - renewPushSubscription(registration, applicationServerKey) - } - } -} diff --git a/ui/src/serviceWorker.ts b/ui/src/serviceWorker.ts index 3d8af7eba..d2ba9cf1b 100755 --- a/ui/src/serviceWorker.ts +++ b/ui/src/serviceWorker.ts @@ -1,5 +1,3 @@ -import { subscribePush } from './notification' - const isLocalhost = Boolean( window.location.hostname === 'localhost' || // [::1] is the IPv6 localhost address. @@ -17,8 +15,6 @@ function registerValidSW(swUrl: string, config?: Config) { navigator.serviceWorker .register(swUrl) .then(registration => { - // Subscribe to Push manager - subscribePush(registration) // Update registration.onupdatefound = () => { const installingWorker = registration.installing diff --git a/ui/src/settings/SettingsPage.tsx b/ui/src/settings/SettingsPage.tsx index 6de10e2d7..1defa3f75 100644 --- a/ui/src/settings/SettingsPage.tsx +++ b/ui/src/settings/SettingsPage.tsx @@ -14,7 +14,6 @@ import AddCategoryForm from './categories/AddCategoryForm' import CategoriesTab from './categories/CategoriesTab' import EditCategoryTab from './categories/EditCategoryTab' import Header from './components/Header' -import NotificationButton from './components/NotificationButton' import Tabs from './components/Tabs' import PreferencesTab from './preferences/PreferencesTab' import AddRuleForm from './rules/AddRuleForm' @@ -32,12 +31,9 @@ const items = [ type AllProps = RouteComponentProps<{}> const Actions = () => ( - <> - - Go to docs - - - + + Go to docs + ) const PageHeader = () => ( diff --git a/ui/src/settings/components/NotificationButton.tsx b/ui/src/settings/components/NotificationButton.tsx deleted file mode 100644 index fb28ce865..000000000 --- a/ui/src/settings/components/NotificationButton.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { useApolloClient, useMutation } from 'react-apollo-hooks' - -import ButtonIcon from '../../components/ButtonIcon' -import { CreatePushSubscriptionResponse, DeletePushSubscriptionResponse, GetDeviceResponse } from './models' -import { CreatePushSubscription, DeletePushSubscription, GetDevice } from './queries' - -const DEVICE_ID = 'device_id' - -export default () => { - const [id, setId] = useState(localStorage.getItem(DEVICE_ID)) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const client = useApolloClient() - - const deletePushSubscriptionMutation = useMutation(DeletePushSubscription) - const deletePushSubscription = async () => { - try { - setLoading(true) - await deletePushSubscriptionMutation({ variables: { id } }) - setId(null) - localStorage.removeItem(DEVICE_ID) - } catch (err) { - setError(err) - } finally { - setLoading(false) - } - } - - const getPushSubscription = async (pushId: string) => { - setLoading(true) - try { - const { errors } = await client.query({ - query: GetDevice, - variables: { id: pushId } - }) - if (errors) { - throw new Error(errors[0].message) - } - } catch (err) { - setError(err) - } finally { - setLoading(false) - } - } - - useEffect(() => { - if (id) { - getPushSubscription(id) - } - }, [id]) - - const createPushSubscriptionMutation = useMutation(CreatePushSubscription) - const createPushSubscription = async () => { - try { - setLoading(true) - const swr = await navigator.serviceWorker.ready - const subscription = await swr.pushManager.getSubscription() - if (subscription) { - const res = await createPushSubscriptionMutation({ - variables: { - sub: JSON.stringify(subscription) - } - }) - if (res.data) { - const _id = res.data.createPushSubscription.id - setId(_id.toString()) - localStorage.setItem(DEVICE_ID, _id.toString()) - } - } - } catch (err) { - setError(err) - } finally { - setLoading(false) - } - } - - const resetSubscription = async (err: Error) => { - if (confirm(`An error occured:\n${err.message}\n\nReset subscription?`)) { - setId(null) - localStorage.removeItem(DEVICE_ID) - } - } - - let title = id ? 'Disable notifications' : 'Enable notifications' - let icon = id ? 'notifications_off' : 'notifications' - let onClick = id ? deletePushSubscription : createPushSubscription - if (error) { - title = error.message - icon = 'notification_important' - onClick = () => resetSubscription(error) - } - - return -} diff --git a/ui/src/settings/preferences/NotificationBox.tsx b/ui/src/settings/preferences/NotificationBox.tsx new file mode 100644 index 000000000..ed7b029a9 --- /dev/null +++ b/ui/src/settings/preferences/NotificationBox.tsx @@ -0,0 +1,151 @@ +import React, { ReactNode, useEffect, useState } from 'react' +import { useApolloClient, useMutation } from 'react-apollo-hooks' +import Switch from 'react-switch' + +import Box from '../../components/Box' +import Button from '../../components/Button' +import Loader from '../../components/Loader' +import ErrorPanel from '../../error/ErrorPanel' +import { isNotificationGranted, isNotificationSupported, subscribePush, unSubscribePush } from '../../helpers' +import { CreatePushSubscriptionResponse, DeletePushSubscriptionResponse, GetDeviceResponse } from '../components/models' +import { CreatePushSubscription, DeletePushSubscription, GetDevice } from '../components/queries' + +const DEVICE_ID = 'device_id' + +interface NotificationSupportProps { + children: ReactNode +} + +const NotificationSupport = ({ children }: NotificationSupportProps) => { + const supported = isNotificationSupported() + const [allowed, setAllowed] = useState(isNotificationGranted()) + + const requestPermission = () => Notification.requestPermission(permission => setAllowed(permission === 'granted')) + + if (!supported) { + return

Sorry, but this browser does not support desktop notification.

+ } else if (!allowed) { + return ( + <> +

Notifications are not yet allowed on your Browser.

+ + + ) + } else { + return <>{children} + } +} + +interface NotificationErrorProps { + reset: () => void + err: Error +} + +const NotificationError = ({ reset, err }: NotificationErrorProps) => ( + reset subscription} + > + {err.message} + +) + +const NotificationSwitch = () => { + const [activated, setActivated] = useState(false) + const [pushID, setPushID] = useState(localStorage.getItem(DEVICE_ID)) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const client = useApolloClient() + const deletePushSubscriptionMutation = useMutation(DeletePushSubscription) + const createPushSubscriptionMutation = useMutation(CreatePushSubscription) + + const resetSubscription = async () => { + localStorage.removeItem(DEVICE_ID) + setPushID(null) + setActivated(false) + try { + const swr = await navigator.serviceWorker.ready + await unSubscribePush(swr) + } catch (err) { + console.error(err) + } + } + + const getPushSubscriptionStatus = async (pushId: string) => { + setLoading(true) + try { + const { errors } = await client.query({ + query: GetDevice, + variables: { id: pushId } + }) + if (errors) { + throw new Error(errors[0].message) + } else { + setActivated(true) + } + } catch (err) { + setError(err) + } finally { + setLoading(false) + } + } + + const subscribe = async () => { + try { + setLoading(true) + const swr = await navigator.serviceWorker.ready + const subscription = await subscribePush(swr) + if (subscription) { + const res = await createPushSubscriptionMutation({ + variables: { + sub: JSON.stringify(subscription) + } + }) + if (res.data) { + const _id = res.data.createPushSubscription.id + setPushID(_id.toString()) + localStorage.setItem(DEVICE_ID, _id.toString()) + } + } + } catch (err) { + setError(err) + } finally { + setLoading(false) + } + } + + const unsubscribe = async () => { + try { + setLoading(true) + await deletePushSubscriptionMutation({ variables: { id: pushID } }) + resetSubscription() + } catch (err) { + setError(err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (pushID) { + getPushSubscriptionStatus(pushID) + } + }, [pushID]) + + return ( + <> + {error != null && } + + {loading && } + + ) +} + +export default () => ( + +

Receive notifications on your device when new articles are available.

+ + + +
+) diff --git a/ui/src/settings/preferences/PreferencesTab.tsx b/ui/src/settings/preferences/PreferencesTab.tsx index 466630a79..c2d392cd5 100644 --- a/ui/src/settings/preferences/PreferencesTab.tsx +++ b/ui/src/settings/preferences/PreferencesTab.tsx @@ -3,6 +3,7 @@ import React from 'react' import Panel from '../../components/Panel' import { usePageTitle } from '../../hooks' import InstallationBox from './InstallationBox' +import NotificationBox from './NotificationBox' import classes from './PreferencesTab.module.css' export default () => { @@ -15,6 +16,7 @@ export default () => {

Preferences on this device.

+ )