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 <ek@stacker.news>
This commit is contained in:
ekzyis 2023-06-12 20:03:44 +02:00 committed by GitHub
parent 876b3e0fdd
commit 069417d130
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 103 additions and 24 deletions

View File

@ -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. // 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 // Formik does this too! When you specify `type` to useField(), it will
// return the correct bag of props for you // return the correct bag of props for you
const [field] = useField({ ...props, type: 'checkbox' }) const [field,, helpers] = useField({ ...props, type: 'checkbox' })
return ( return (
<BootstrapForm.Group className={groupClassName}> <BootstrapForm.Group className={groupClassName}>
{hiddenLabel && <BootstrapForm.Label className='invisible'>{label}</BootstrapForm.Label>} {hiddenLabel && <BootstrapForm.Label className='invisible'>{label}</BootstrapForm.Label>}
@ -394,12 +394,12 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
inline={inline} inline={inline}
> >
<BootstrapForm.Check.Input <BootstrapForm.Check.Input
{...field} {...props} type='checkbox' onChange={(e) => { {...field} {...props} disabled={disabled} type='checkbox' onChange={(e) => {
field.onChange(e) field.onChange(e)
handleChange && handleChange(e.target.checked) handleChange && handleChange(e.target.checked, helpers.setValue)
}} }}
/> />
<BootstrapForm.Check.Label className='d-flex'> <BootstrapForm.Check.Label className={'d-flex' + (disabled ? ' text-muted' : '')}>
<div className='flex-grow-1'>{label}</div> <div className='flex-grow-1'>{label}</div>
{extra && {extra &&
<div className={styles.checkboxExtra}> <div className={styles.checkboxExtra}>

View File

@ -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 { useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment' import Comment, { CommentSkeleton } from './comment'
import Item from './item' import Item from './item'
@ -16,7 +16,8 @@ import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
import CowboyHatIcon from '../svgs/cowboy.svg' import CowboyHatIcon from '../svgs/cowboy.svg'
import BaldIcon from '../svgs/bald.svg' import BaldIcon from '../svgs/bald.svg'
import { RootProvider } from './root' import { RootProvider } from './root'
import { useMe } from './me' import { Alert } from 'react-bootstrap'
import styles from './notifications.module.css'
function Notification ({ n }) { function Notification ({ n }) {
switch (n.__typename) { 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
? (
<Alert variant='success' className='text-center' dismissible onClose={close}>
<span className='align-middle'>Enable push notifications?</span>
<button
className={`${styles.alertBtn} mx-1`}
onClick={() => {
pushNotify.requestPermission()
close()
}}
>Yes
</button>
<button className={`${styles.alertBtn}`} onClick={close}>No</button>
</Alert>
)
: null
)
}
export default function Notifications ({ notifications, earn, cursor, lastChecked, variables }) { export default function Notifications ({ notifications, earn, cursor, lastChecked, variables }) {
const { data, fetchMore } = useQuery(NOTIFICATIONS, { variables }) const { data, fetchMore } = useQuery(NOTIFICATIONS, { variables })
@ -267,6 +301,7 @@ export default function Notifications ({ notifications, earn, cursor, lastChecke
return ( return (
<> <>
<NotificationAlert />
{/* XXX we shouldn't use the index but we don't have a unique id in this union yet */} {/* XXX we shouldn't use the index but we don't have a unique id in this union yet */}
<div className='fresh'> <div className='fresh'>
{earn && <Notification n={earn} key='earn' />} {earn && <Notification n={earn} key='earn' />}
@ -298,10 +333,18 @@ const NotificationContext = createContext({})
export const NotificationProvider = ({ children }) => { export const NotificationProvider = ({ children }) => {
const isBrowser = typeof window !== 'undefined' const isBrowser = typeof window !== 'undefined'
const [isSupported] = useState(isBrowser ? 'Notification' in window : false) const [isSupported] = useState(isBrowser ? 'Notification' in window : false)
const [isDefaultPermission, setIsDefaultPermission] = useState(isSupported ? window.Notification.permission === 'default' : undefined) const [permission, setPermission_] = useState(
const [isGranted, setIsGranted] = useState(isSupported ? window.Notification.permission === 'granted' : undefined) isSupported
const me = useMe() ? window.Notification.permission === 'granted'
const router = useRouter() // 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 show_ = (title, options) => {
const icon = '/android-chrome-24x24.png' const icon = '/android-chrome-24x24.png'
@ -313,22 +356,22 @@ export const NotificationProvider = ({ children }) => {
show_(...args) show_(...args)
}, [isGranted]) }, [isGranted])
const requestPermission = useCallback(() => { const setPermission = useCallback((perm) => {
localStorage.setItem('notify-permission', perm)
setPermission_(perm)
}, [])
const requestPermission = useCallback((cb) => {
window.Notification.requestPermission().then(result => { window.Notification.requestPermission().then(result => {
setIsDefaultPermission(window.Notification.permission === 'default') setPermission(window.Notification.permission)
if (result === 'granted') { if (result === 'granted') show_('Stacker News notifications enabled')
setIsGranted(result === 'granted') cb?.(result)
show_('Stacker News notifications enabled')
}
}) })
}, [isDefaultPermission]) }, [])
useEffect(() => { const withdrawPermission = useCallback(() => isGranted ? setPermission('withdrawn') : null, [isGranted])
if (!me || !isSupported || !isDefaultPermission || router.pathname !== '/notifications') return
requestPermission()
}, [router?.pathname])
const ctx = { isBrowser, isSupported, isDefaultPermission, isGranted, show } const ctx = { isBrowser, isSupported, isDefault, isGranted, isDenied, isWithdrawn, requestPermission, withdrawPermission, show }
return <NotificationContext.Provider value={ctx}>{children}</NotificationContext.Provider> return <NotificationContext.Provider value={ctx}>{children}</NotificationContext.Provider>
} }

View File

@ -11,4 +11,15 @@
.fresh { .fresh {
background-color: rgba(0, 0, 0, 0.03); background-color: rgba(0, 0, 0, 0.03);
border-radius: .4rem; 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;
} }

View File

@ -17,6 +17,7 @@ import { bech32 } from 'bech32'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32 } from '../lib/nostr' import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32 } from '../lib/nostr'
import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '../lib/validate' import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '../lib/validate'
import { SUPPORTED_CURRENCIES } from '../lib/currency' import { SUPPORTED_CURRENCIES } from '../lib/currency'
import { useNotification } from '../components/notifications'
export const getServerSideProps = getGetServerSideProps(SETTINGS) export const getServerSideProps = getGetServerSideProps(SETTINGS)
@ -39,6 +40,7 @@ export default function Settings ({ data: { settings } }) {
} }
} }
) )
const pushNotify = useNotification()
const { data } = useQuery(SETTINGS) const { data } = useQuery(SETTINGS)
if (data) { if (data) {
@ -52,6 +54,7 @@ export default function Settings ({ data: { settings } }) {
<Form <Form
initial={{ initial={{
tipDefault: settings?.tipDefault || 21, tipDefault: settings?.tipDefault || 21,
pushNotify: pushNotify.isSupported && pushNotify.isGranted,
turboTipping: settings?.turboTipping, turboTipping: settings?.turboTipping,
fiatCurrency: settings?.fiatCurrency || 'USD', fiatCurrency: settings?.fiatCurrency || 'USD',
noteItemSats: settings?.noteItemSats, noteItemSats: settings?.noteItemSats,
@ -143,7 +146,29 @@ export default function Settings ({ data: { settings } }) {
items={SUPPORTED_CURRENCIES} items={SUPPORTED_CURRENCIES}
required required
/> />
<div className='form-label'>notify me when ...</div> {
pushNotify.isSupported
? (
<>
<div className='form-label'>notify me</div>
<Checkbox
disabled={pushNotify.isDenied}
label='with push notifications'
name='pushNotify'
groupClassName='mb-0'
handleChange={(val, setValue) => {
val
? pushNotify.requestPermission(result => {
if (result === 'denied') setValue(false)
})
: pushNotify.withdrawPermission()
}}
/>
</>
)
: <div className='form-label'>notify me when ...</div>
}
<div className='form-label'>when ...</div>
<Checkbox <Checkbox
label='I stack sats from posts and comments' label='I stack sats from posts and comments'
name='noteItemSats' name='noteItemSats'