* Prototype of toast system

* More toast adoption

* share
* flag
* bookmark
* subscribe
* delete

* More toast usage:

* forms
* settings save

* Log error during flag failure

* Incorporate PR feedback:

1. return `toaster` from `useToast` hook, with simplified `success` and `danger` methods

2. remove toast header, move close button to body

3. change how toast ids are generated to use a global incrementing int

4. update toast messages

* PR feedback:

* reduce width of toast on narrow screens
* dynamic delete success toast message based on deleted type
* add toasts to auth methods deletion operations

* Dismiss all toasts upon page navigation

* refine style and use delay prop

* more styling

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
SatsAllDay 2023-08-25 19:21:51 -04:00 committed by GitHub
parent 61c7bb28c2
commit 0ee056b2a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 250 additions and 61 deletions

View File

@ -1,8 +1,10 @@
import { useMutation } from '@apollo/client' import { useMutation } from '@apollo/client'
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import Dropdown from 'react-bootstrap/Dropdown' import Dropdown from 'react-bootstrap/Dropdown'
import { useToast } from './toast'
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) { export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
const toaster = useToast()
const [bookmarkItem] = useMutation( const [bookmarkItem] = useMutation(
gql` gql`
mutation bookmarkItem($id: ID!) { mutation bookmarkItem($id: ID!) {
@ -22,7 +24,15 @@ export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
) )
return ( return (
<Dropdown.Item <Dropdown.Item
onClick={() => bookmarkItem({ variables: { id } })} onClick={async () => {
try {
await bookmarkItem({ variables: { id } })
toaster.success(meBookmark ? 'bookmark removed' : 'bookmark added')
} catch (err) {
console.error(err)
toaster.danger(meBookmark ? 'failed to remove bookmark' : 'failed to bookmark')
}
}}
> >
{meBookmark ? 'remove bookmark' : 'bookmark'} {meBookmark ? 'remove bookmark' : 'bookmark'}
</Dropdown.Item> </Dropdown.Item>

View File

@ -51,7 +51,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
required required
/> />
<div className='d-flex justify-content-between'> <div className='d-flex justify-content-between'>
<Delete itemId={comment.id} onDelete={onSuccess}> <Delete itemId={comment.id} onDelete={onSuccess} type='comment'>
<Button variant='grey-medium'>delete</Button> <Button variant='grey-medium'>delete</Button>
</Delete> </Delete>
<EditFeeButton <EditFeeButton

View File

@ -5,8 +5,9 @@ import Alert from 'react-bootstrap/Alert'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import Dropdown from 'react-bootstrap/Dropdown' import Dropdown from 'react-bootstrap/Dropdown'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useToast } from './toast'
export default function Delete ({ itemId, children, onDelete }) { export default function Delete ({ itemId, children, onDelete, type = 'post' }) {
const showModal = useShowModal() const showModal = useShowModal()
const [deleteItem] = useMutation( const [deleteItem] = useMutation(
@ -40,6 +41,7 @@ export default function Delete ({ itemId, children, onDelete }) {
showModal(onClose => { showModal(onClose => {
return ( return (
<DeleteConfirm <DeleteConfirm
type={type}
onConfirm={async () => { onConfirm={async () => {
const { error } = await deleteItem({ variables: { id: itemId } }) const { error } = await deleteItem({ variables: { id: itemId } })
if (error) { if (error) {
@ -59,8 +61,9 @@ export default function Delete ({ itemId, children, onDelete }) {
) )
} }
function DeleteConfirm ({ onConfirm }) { function DeleteConfirm ({ onConfirm, type }) {
const [error, setError] = useState() const [error, setError] = useState()
const toaster = useToast()
return ( return (
<> <>
@ -71,6 +74,7 @@ function DeleteConfirm ({ onConfirm }) {
variant='danger' onClick={async () => { variant='danger' onClick={async () => {
try { try {
await onConfirm() await onConfirm()
toaster.success(`deleted ${type.toLowerCase()}`)
} catch (e) { } catch (e) {
setError(e.message || e) setError(e.message || e)
} }

View File

@ -2,8 +2,10 @@ import { gql, useMutation } from '@apollo/client'
import Dropdown from 'react-bootstrap/Dropdown' import Dropdown from 'react-bootstrap/Dropdown'
import FundError from './fund-error' import FundError from './fund-error'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useToast } from './toast'
export default function DontLikeThisDropdownItem ({ id }) { export default function DontLikeThisDropdownItem ({ id }) {
const toaster = useToast()
const showModal = useShowModal() const showModal = useShowModal()
const [dontLikeThis] = useMutation( const [dontLikeThis] = useMutation(
@ -32,11 +34,15 @@ export default function DontLikeThisDropdownItem ({ id }) {
variables: { id }, variables: { id },
optimisticResponse: { dontLikeThis: true } optimisticResponse: { dontLikeThis: true }
}) })
toaster.success('item flagged')
} catch (error) { } catch (error) {
console.error(error)
if (error.toString().includes('insufficient funds')) { if (error.toString().includes('insufficient funds')) {
showModal(onClose => { showModal(onClose => {
return <FundError onClose={onClose} /> return <FundError onClose={onClose} />
}) })
} else {
toaster.danger('failed to flag item')
} }
} }
}} }}

View File

@ -1,7 +1,6 @@
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import BootstrapForm from 'react-bootstrap/Form' import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik' import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik'
import React, { createContext, useContext, useEffect, useRef, useState } from 'react' import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
import copy from 'clipboard-copy' import copy from 'clipboard-copy'
@ -19,6 +18,7 @@ import CloseIcon from '../svgs/close-line.svg'
import { useLazyQuery } from '@apollo/client' import { useLazyQuery } from '@apollo/client'
import { USER_SEARCH } from '../fragments/users' import { USER_SEARCH } from '../fragments/users'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast'
export function SubmitButton ({ export function SubmitButton ({
children, variant, value, onClick, disabled, ...props children, variant, value, onClick, disabled, ...props
@ -472,7 +472,12 @@ const StorageKeyPrefixContext = createContext()
export function Form ({ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, ...props initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, ...props
}) { }) {
const [error, setError] = useState(initialError) const toaster = useToast()
useEffect(() => {
if (initialError) {
toaster.danger(initialError.message || initialError.toString?.())
}
}, [])
return ( return (
<Formik <Formik
@ -481,26 +486,31 @@ export function Form ({
validationSchema={schema} validationSchema={schema}
initialTouched={validateImmediately && initial} initialTouched={validateImmediately && initial}
validateOnBlur={false} validateOnBlur={false}
onSubmit={async (values, ...args) => onSubmit={async (values, ...args) => {
onSubmit && onSubmit(values, ...args).then((options) => { try {
if (!storageKeyPrefix || options?.keepLocalStorage) return if (onSubmit) {
Object.keys(values).forEach(v => { const options = await onSubmit(values, ...args)
window.localStorage.removeItem(storageKeyPrefix + '-' + v) if (!storageKeyPrefix || options?.keepLocalStorage) return
if (Array.isArray(values[v])) { Object.keys(values).forEach(v => {
values[v].forEach( window.localStorage.removeItem(storageKeyPrefix + '-' + v)
(iv, i) => { if (Array.isArray(values[v])) {
Object.keys(iv).forEach(k => { values[v].forEach(
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}].${k}`) (iv, i) => {
Object.keys(iv).forEach(k => {
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}].${k}`)
})
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`)
}) })
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`) }
}) })
}
} }
) } catch (err) {
}).catch(e => setError(e.message || e))} console.log(err)
toaster.danger(err.message || err.toString?.())
}
}}
> >
<FormikForm {...props} noValidate> <FormikForm {...props} noValidate>
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}
<StorageKeyPrefixContext.Provider value={storageKeyPrefix}> <StorageKeyPrefixContext.Provider value={storageKeyPrefix}>
{children} {children}
</StorageKeyPrefixContext.Provider> </StorageKeyPrefixContext.Provider>

View File

@ -140,7 +140,7 @@ export default function ItemInfo ({
{me && !item.meSats && !item.position && !item.meDontLike && {me && !item.meSats && !item.position && !item.meDontLike &&
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />} !item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
{item.mine && !item.position && !item.deletedAt && {item.mine && !item.position && !item.deletedAt &&
<DeleteDropdownItem itemId={item.id} />} <DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />}
</ItemDropdown> </ItemDropdown>
{extraInfo} {extraInfo}
</div> </div>

View File

@ -2,9 +2,11 @@ import Dropdown from 'react-bootstrap/Dropdown'
import ShareIcon from '../svgs/share-fill.svg' import ShareIcon from '../svgs/share-fill.svg'
import copy from 'clipboard-copy' import copy from 'clipboard-copy'
import { useMe } from './me' import { useMe } from './me'
import { useToast } from './toast'
export default function Share ({ item }) { export default function Share ({ item }) {
const me = useMe() const me = useMe()
const toaster = useToast()
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}` const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
return typeof window !== 'undefined' && navigator?.share return typeof window !== 'undefined' && navigator?.share
@ -13,16 +15,17 @@ export default function Share ({ item }) {
<ShareIcon <ShareIcon
width={20} height={20} width={20} height={20}
className='mx-2 fill-grey theme' className='mx-2 fill-grey theme'
onClick={() => { onClick={async () => {
if (navigator.share) { try {
navigator.share({ await navigator.share({
title: item.title || '', title: item.title || '',
text: '', text: '',
url url
}).then(() => console.log('Successful share')) })
.catch((error) => console.log('Error sharing', error)) toaster.success('link shared')
} else { } catch (err) {
console.log('no navigator.share') console.error(err)
toaster.danger('failed to share link')
} }
}} }}
/> />
@ -36,7 +39,13 @@ export default function Share ({ item }) {
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Item <Dropdown.Item
onClick={async () => { onClick={async () => {
copy(url) try {
await copy(url)
toaster.success('link copied')
} catch (err) {
console.error(err)
toaster.danger('failed to copy link')
}
}} }}
> >
copy link copy link
@ -47,19 +56,25 @@ export default function Share ({ item }) {
export function CopyLinkDropdownItem ({ item }) { export function CopyLinkDropdownItem ({ item }) {
const me = useMe() const me = useMe()
const toaster = useToast()
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}` const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
return ( return (
<Dropdown.Item <Dropdown.Item
onClick={async () => { onClick={async () => {
if (navigator.share) { try {
navigator.share({ if (navigator.share) {
title: item.title || '', await navigator.share({
text: '', title: item.title || '',
url text: '',
}).then(() => console.log('Successful share')) url
.catch((error) => console.log('Error sharing', error)) })
} else { } else {
copy(url) await copy(url)
}
toaster.success('link copied')
} catch (err) {
console.error(err)
toaster.danger('failed to copy link')
} }
}} }}
> >

View File

@ -1,8 +1,10 @@
import { useMutation } from '@apollo/client' import { useMutation } from '@apollo/client'
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import Dropdown from 'react-bootstrap/Dropdown' import Dropdown from 'react-bootstrap/Dropdown'
import { useToast } from './toast'
export default function SubscribeDropdownItem ({ item: { id, meSubscription } }) { export default function SubscribeDropdownItem ({ item: { id, meSubscription } }) {
const toaster = useToast()
const [subscribeItem] = useMutation( const [subscribeItem] = useMutation(
gql` gql`
mutation subscribeItem($id: ID!) { mutation subscribeItem($id: ID!) {
@ -22,7 +24,15 @@ export default function SubscribeDropdownItem ({ item: { id, meSubscription } })
) )
return ( return (
<Dropdown.Item <Dropdown.Item
onClick={() => subscribeItem({ variables: { id } })} onClick={async () => {
try {
await subscribeItem({ variables: { id } })
toaster.success(meSubscription ? 'unsubscribed' : 'subscribed')
} catch (err) {
console.error(err)
toaster.danger(meSubscription ? 'failed to unsubscribe' : 'failed to subscribe')
}
}}
> >
{meSubscription ? 'remove subscription' : 'subscribe'} {meSubscription ? 'remove subscription' : 'subscribe'}
</Dropdown.Item> </Dropdown.Item>

81
components/toast.js Normal file
View File

@ -0,0 +1,81 @@
import { useRouter } from 'next/router'
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import Button from 'react-bootstrap/Button'
import Toast from 'react-bootstrap/Toast'
import ToastBody from 'react-bootstrap/ToastBody'
import ToastContainer from 'react-bootstrap/ToastContainer'
import styles from './toast.module.css'
const ToastContext = createContext(() => {})
export const ToastProvider = ({ children }) => {
const router = useRouter()
const [toasts, setToasts] = useState([])
const toastId = useRef(0)
const dispatchToast = useCallback((toastConfig) => {
toastConfig = {
...toastConfig,
id: toastId.current++
}
setToasts(toasts => [...toasts, toastConfig])
}, [])
const toaster = useMemo(() => ({
success: body => {
dispatchToast({
body,
variant: 'success',
autohide: true,
delay: 5000
})
},
danger: body => {
dispatchToast({
body,
variant: 'danger',
autohide: false
})
}
}), [dispatchToast])
const removeToast = useCallback(id => {
setToasts(toasts => toasts.filter(toast => toast.id !== id))
}, [])
// Clear all toasts on page navigation
useEffect(() => {
const handleRouteChangeStart = () => setToasts([])
router.events.on('routeChangeStart', handleRouteChangeStart)
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart)
}
}, [router])
return (
<ToastContext.Provider value={toaster}>
<ToastContainer className='pb-3 pe-3' position='bottom-end' containerPosition='fixed'>
{toasts.map(toast => (
<Toast
key={toast.id} bg={toast.variant} show autohide={toast.autohide}
delay={toast.delay} className={styles.toast} onClose={() => removeToast(toast.id)}
>
<ToastBody>
<div className='d-flex align-items-center'>
<div className='flex-grow-1'>{toast.body}</div>
<Button
variant={null}
className='p-0 ps-2'
aria-label='close'
onClick={() => removeToast(toast.id)}
><div className={styles.toastClose}>X</div>
</Button>
</div>
</ToastBody>
</Toast>
))}
</ToastContainer>
{children}
</ToastContext.Provider>
)
}
export const useToast = () => useContext(ToastContext)

View File

@ -0,0 +1,26 @@
.toast {
width: auto;
border: 1px solid var(--bs-success-border-subtle);
color: #fff;
}
.toastClose {
color: #fff;
font-family: "lightning";
font-size: 150%;
line-height: 1rem;
margin-bottom: -0.25rem;
cursor: pointer;
display: flex;
align-items: center;
}
.toastClose:hover {
opacity: 0.7;
}
@media screen and (min-width: 400px) {
.toast {
width: var(--bs-toast-max-width);
}
}

View File

@ -10,6 +10,7 @@ import { useEffect } from 'react'
import { ShowModalProvider } from '../components/modal' import { ShowModalProvider } from '../components/modal'
import ErrorBoundary from '../components/error-boundary' import ErrorBoundary from '../components/error-boundary'
import { LightningProvider } from '../components/lightning' import { LightningProvider } from '../components/lightning'
import { ToastProvider } from '../components/toast'
import { ServiceWorkerProvider } from '../components/serviceworker' import { ServiceWorkerProvider } from '../components/serviceworker'
import { SSR } from '../lib/constants' import { SSR } from '../lib/constants'
import NProgress from 'nprogress' import NProgress from 'nprogress'
@ -90,11 +91,13 @@ function MyApp ({ Component, pageProps: { ...props } }) {
<ServiceWorkerProvider> <ServiceWorkerProvider>
<PriceProvider price={price}> <PriceProvider price={price}>
<LightningProvider> <LightningProvider>
<PaymentTokenProvider> <ToastProvider>
<ShowModalProvider> <PaymentTokenProvider>
<Component ssrData={ssrData} {...otherProps} /> <ShowModalProvider>
</ShowModalProvider> <Component ssrData={ssrData} {...otherProps} />
</PaymentTokenProvider> </ShowModalProvider>
</PaymentTokenProvider>
</ToastProvider>
</LightningProvider> </LightningProvider>
</PriceProvider> </PriceProvider>
</ServiceWorkerProvider> </ServiceWorkerProvider>

View File

@ -22,6 +22,7 @@ import PageLoading from '../components/page-loading'
import { useShowModal } from '../components/modal' import { useShowModal } from '../components/modal'
import { authErrorMessage } from '../components/login' import { authErrorMessage } from '../components/login'
import { NostrAuth } from '../components/nostr-auth' import { NostrAuth } from '../components/nostr-auth'
import { useToast } from '../components/toast'
export const getServerSideProps = getGetServerSideProps(SETTINGS) export const getServerSideProps = getGetServerSideProps(SETTINGS)
@ -30,7 +31,7 @@ function bech32encode (hexString) {
} }
export default function Settings ({ ssrData }) { export default function Settings ({ ssrData }) {
const [success, setSuccess] = useState() const toaster = useToast()
const [setSettings] = useMutation(SET_SETTINGS, { const [setSettings] = useMutation(SET_SETTINGS, {
update (cache, { data: { setSettings } }) { update (cache, { data: { setSettings } }) {
cache.modify({ cache.modify({
@ -89,18 +90,22 @@ export default function Settings ({ ssrData }) {
const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0) const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0)
await setSettings({ try {
variables: { await setSettings({
tipDefault: Number(tipDefault), variables: {
nostrPubkey, tipDefault: Number(tipDefault),
nostrRelays: nostrRelaysFiltered, nostrPubkey,
...values nostrRelays: nostrRelaysFiltered,
} ...values
}) }
setSuccess('settings saved') })
toaster.success('saved settings')
} catch (err) {
console.error(err)
toaster.danger('failed to save settings')
}
}} }}
> >
{success && <Alert variant='info' onClose={() => setSuccess(undefined)} dismissible>{success}</Alert>}
<Input <Input
label='zap default' label='zap default'
name='tipDefault' name='tipDefault'
@ -329,6 +334,7 @@ function NostrLinkButton ({ unlink, status }) {
function UnlinkObstacle ({ onClose, type, unlinkAuth }) { function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
const router = useRouter() const router = useRouter()
const toaster = useToast()
return ( return (
<div> <div>
@ -344,9 +350,15 @@ function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
}} }}
schema={lastAuthRemovalSchema} schema={lastAuthRemovalSchema}
onSubmit={async () => { onSubmit={async () => {
await unlinkAuth({ variables: { authType: type } }) try {
router.push('/settings') await unlinkAuth({ variables: { authType: type } })
onClose() router.push('/settings')
onClose()
toaster.success('unlinked auth method')
} catch (err) {
console.error(err)
toaster.danger('failed to unlink auth method')
}
}} }}
> >
<Input <Input
@ -362,6 +374,7 @@ function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
function AuthMethods ({ methods }) { function AuthMethods ({ methods }) {
const showModal = useShowModal() const showModal = useShowModal()
const router = useRouter() const router = useRouter()
const toaster = useToast()
const [err, setErr] = useState(authErrorMessage(router.query.error)) const [err, setErr] = useState(authErrorMessage(router.query.error))
const [unlinkAuth] = useMutation( const [unlinkAuth] = useMutation(
gql` gql`
@ -396,7 +409,13 @@ function AuthMethods ({ methods }) {
if (links === 1) { if (links === 1) {
showModal(onClose => (<UnlinkObstacle onClose={onClose} type={type} unlinkAuth={unlinkAuth} />)) showModal(onClose => (<UnlinkObstacle onClose={onClose} type={type} unlinkAuth={unlinkAuth} />))
} else { } else {
await unlinkAuth({ variables: { authType: type } }) try {
await unlinkAuth({ variables: { authType: type } })
toaster.success('unlinked auth method')
} catch (err) {
console.error(err)
toaster.danger('failed to unlink auth method')
}
} }
} }

View File

@ -90,6 +90,7 @@ $popover-body-padding-y: .5rem;
$popover-max-width: 320px !default; $popover-max-width: 320px !default;
$popover-border-color: var(--theme-borderColor); $popover-border-color: var(--theme-borderColor);
$grid-gutter-width: 2rem; $grid-gutter-width: 2rem;
$toast-spacing: .5rem;
:root, [data-bs-theme=light] { :root, [data-bs-theme=light] {
--theme-navLink: rgba(0, 0, 0, 0.55); --theme-navLink: rgba(0, 0, 0, 0.55);
@ -153,6 +154,10 @@ $grid-gutter-width: 2rem;
height: 2px !important; height: 2px !important;
} }
.toast-body {
padding: 0.75rem 1.25rem !important;
}
#nprogress .peg { #nprogress .peg {
box-shadow: 0 0 10px var(--bs-primary), 0 0 5px var(--bs-primary) !important; box-shadow: 0 0 10px var(--bs-primary), 0 0 5px var(--bs-primary) !important;
} }