From 069417d130a363b6fee04c83f0180c83f009e933 Mon Sep 17 00:00:00 2001 From: ekzyis <27162016+ekzyis@users.noreply.github.com> Date: Mon, 12 Jun 2023 20:03:44 +0200 Subject: [PATCH] Enable push notifications in settings (#301) * Enable push notifications in settings * Fix checkbox still checked after user denied permission The error was related to me thinking that the value prop does anything. It didn't. The value of the checkbox is handled by formik. So the solution was to hook into formik and use the handler which actually changes the value. * Add double opt-in to /notifications * Better styling of alert in /notifications --------- Co-authored-by: ekzyis --- components/form.js | 10 ++-- components/notifications.js | 79 ++++++++++++++++++++++------- components/notifications.module.css | 11 ++++ pages/settings.js | 27 +++++++++- 4 files changed, 103 insertions(+), 24 deletions(-) diff --git a/components/form.js b/components/form.js index 85ecf814..e2053e1c 100644 --- a/components/form.js +++ b/components/form.js @@ -380,11 +380,11 @@ export function VariableInput ({ label, groupClassName, name, hint, max, min, re ) } -export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, ...props }) { +export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, disabled, ...props }) { // React treats radios and checkbox inputs differently other input types, select, and textarea. // Formik does this too! When you specify `type` to useField(), it will // return the correct bag of props for you - const [field] = useField({ ...props, type: 'checkbox' }) + const [field,, helpers] = useField({ ...props, type: 'checkbox' }) return ( {hiddenLabel && {label}} @@ -394,12 +394,12 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, inline={inline} > { + {...field} {...props} disabled={disabled} type='checkbox' onChange={(e) => { field.onChange(e) - handleChange && handleChange(e.target.checked) + handleChange && handleChange(e.target.checked, helpers.setValue) }} /> - +
{label}
{extra &&
diff --git a/components/notifications.js b/components/notifications.js index c52c825d..95212f8d 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useContext, createContext } from 'react' +import { useState, useCallback, useContext, useEffect, createContext } from 'react' import { useQuery } from '@apollo/client' import Comment, { CommentSkeleton } from './comment' import Item from './item' @@ -16,7 +16,8 @@ import { COMMENT_DEPTH_LIMIT } from '../lib/constants' import CowboyHatIcon from '../svgs/cowboy.svg' import BaldIcon from '../svgs/bald.svg' import { RootProvider } from './root' -import { useMe } from './me' +import { Alert } from 'react-bootstrap' +import styles from './notifications.module.css' function Notification ({ n }) { switch (n.__typename) { @@ -251,6 +252,39 @@ function Reply ({ n }) { ) } +function NotificationAlert () { + const [showAlert, setShowAlert] = useState(false) + const pushNotify = useNotification() + + useEffect(() => { + setShowAlert(!localStorage.getItem('hideNotifyPrompt')) + }, []) + + const close = () => { + localStorage.setItem('hideNotifyPrompt', 'yep') + setShowAlert(false) + } + + return ( + showAlert + ? ( + + Enable push notifications? + + + + ) + : null + ) +} + export default function Notifications ({ notifications, earn, cursor, lastChecked, variables }) { const { data, fetchMore } = useQuery(NOTIFICATIONS, { variables }) @@ -267,6 +301,7 @@ export default function Notifications ({ notifications, earn, cursor, lastChecke return ( <> + {/* XXX we shouldn't use the index but we don't have a unique id in this union yet */}
{earn && } @@ -298,10 +333,18 @@ const NotificationContext = createContext({}) export const NotificationProvider = ({ children }) => { const isBrowser = typeof window !== 'undefined' const [isSupported] = useState(isBrowser ? 'Notification' in window : false) - const [isDefaultPermission, setIsDefaultPermission] = useState(isSupported ? window.Notification.permission === 'default' : undefined) - const [isGranted, setIsGranted] = useState(isSupported ? window.Notification.permission === 'granted' : undefined) - const me = useMe() - const router = useRouter() + const [permission, setPermission_] = useState( + isSupported + ? window.Notification.permission === 'granted' + // if permission was granted, we need to check if user has withdrawn permission using the settings + // since requestPermission only works once + ? localStorage.getItem('notify-permission') ?? window.Notification.permission + : window.Notification.permission + : 'unsupported') + const isDefault = permission === 'default' + const isGranted = permission === 'granted' + const isDenied = permission === 'denied' + const isWithdrawn = permission === 'withdrawn' const show_ = (title, options) => { const icon = '/android-chrome-24x24.png' @@ -313,22 +356,22 @@ export const NotificationProvider = ({ children }) => { show_(...args) }, [isGranted]) - const requestPermission = useCallback(() => { + const setPermission = useCallback((perm) => { + localStorage.setItem('notify-permission', perm) + setPermission_(perm) + }, []) + + const requestPermission = useCallback((cb) => { window.Notification.requestPermission().then(result => { - setIsDefaultPermission(window.Notification.permission === 'default') - if (result === 'granted') { - setIsGranted(result === 'granted') - show_('Stacker News notifications enabled') - } + setPermission(window.Notification.permission) + if (result === 'granted') show_('Stacker News notifications enabled') + cb?.(result) }) - }, [isDefaultPermission]) + }, []) - useEffect(() => { - if (!me || !isSupported || !isDefaultPermission || router.pathname !== '/notifications') return - requestPermission() - }, [router?.pathname]) + const withdrawPermission = useCallback(() => isGranted ? setPermission('withdrawn') : null, [isGranted]) - const ctx = { isBrowser, isSupported, isDefaultPermission, isGranted, show } + const ctx = { isBrowser, isSupported, isDefault, isGranted, isDenied, isWithdrawn, requestPermission, withdrawPermission, show } return {children} } diff --git a/components/notifications.module.css b/components/notifications.module.css index 343e1ca0..6b6a9e3a 100644 --- a/components/notifications.module.css +++ b/components/notifications.module.css @@ -11,4 +11,15 @@ .fresh { background-color: rgba(0, 0, 0, 0.03); border-radius: .4rem; +} + +.alertBtn { + display: inline-block; + font-weight: bold; + text-align: center; + vertical-align: middle; + user-select: none; + background-color: transparent; + border: 0 solid transparent; + font-size: 0.9rem; } \ No newline at end of file diff --git a/pages/settings.js b/pages/settings.js index 4bef4079..8a6ad7b1 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -17,6 +17,7 @@ import { bech32 } from 'bech32' import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32 } from '../lib/nostr' import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '../lib/validate' import { SUPPORTED_CURRENCIES } from '../lib/currency' +import { useNotification } from '../components/notifications' export const getServerSideProps = getGetServerSideProps(SETTINGS) @@ -39,6 +40,7 @@ export default function Settings ({ data: { settings } }) { } } ) + const pushNotify = useNotification() const { data } = useQuery(SETTINGS) if (data) { @@ -52,6 +54,7 @@ export default function Settings ({ data: { settings } }) {
-
notify me when ...
+ { + pushNotify.isSupported + ? ( + <> +
notify me
+ { + val + ? pushNotify.requestPermission(result => { + if (result === 'denied') setValue(false) + }) + : pushNotify.withdrawPermission() + }} + /> + + ) + :
notify me when ...
+ } +
when ...