* 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 { gql } from 'graphql-tag'
import Dropdown from 'react-bootstrap/Dropdown'
import { useToast } from './toast'
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
const toaster = useToast()
const [bookmarkItem] = useMutation(
gql`
mutation bookmarkItem($id: ID!) {
@ -22,7 +24,15 @@ export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
)
return (
<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'}
</Dropdown.Item>

View File

@ -51,7 +51,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
required
/>
<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>
</Delete>
<EditFeeButton

View File

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

View File

@ -2,8 +2,10 @@ import { gql, useMutation } from '@apollo/client'
import Dropdown from 'react-bootstrap/Dropdown'
import FundError from './fund-error'
import { useShowModal } from './modal'
import { useToast } from './toast'
export default function DontLikeThisDropdownItem ({ id }) {
const toaster = useToast()
const showModal = useShowModal()
const [dontLikeThis] = useMutation(
@ -32,11 +34,15 @@ export default function DontLikeThisDropdownItem ({ id }) {
variables: { id },
optimisticResponse: { dontLikeThis: true }
})
toaster.success('item flagged')
} catch (error) {
console.error(error)
if (error.toString().includes('insufficient funds')) {
showModal(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 InputGroup from 'react-bootstrap/InputGroup'
import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik'
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
import copy from 'clipboard-copy'
@ -19,6 +18,7 @@ import CloseIcon from '../svgs/close-line.svg'
import { useLazyQuery } from '@apollo/client'
import { USER_SEARCH } from '../fragments/users'
import TextareaAutosize from 'react-textarea-autosize'
import { useToast } from './toast'
export function SubmitButton ({
children, variant, value, onClick, disabled, ...props
@ -472,7 +472,12 @@ const StorageKeyPrefixContext = createContext()
export function Form ({
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 (
<Formik
@ -481,26 +486,31 @@ export function Form ({
validationSchema={schema}
initialTouched={validateImmediately && initial}
validateOnBlur={false}
onSubmit={async (values, ...args) =>
onSubmit && onSubmit(values, ...args).then((options) => {
if (!storageKeyPrefix || options?.keepLocalStorage) return
Object.keys(values).forEach(v => {
window.localStorage.removeItem(storageKeyPrefix + '-' + v)
if (Array.isArray(values[v])) {
values[v].forEach(
(iv, i) => {
Object.keys(iv).forEach(k => {
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}].${k}`)
onSubmit={async (values, ...args) => {
try {
if (onSubmit) {
const options = await onSubmit(values, ...args)
if (!storageKeyPrefix || options?.keepLocalStorage) return
Object.keys(values).forEach(v => {
window.localStorage.removeItem(storageKeyPrefix + '-' + v)
if (Array.isArray(values[v])) {
values[v].forEach(
(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(e => setError(e.message || e))}
} catch (err) {
console.log(err)
toaster.danger(err.message || err.toString?.())
}
}}
>
<FormikForm {...props} noValidate>
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}
<StorageKeyPrefixContext.Provider value={storageKeyPrefix}>
{children}
</StorageKeyPrefixContext.Provider>

View File

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

View File

@ -2,9 +2,11 @@ import Dropdown from 'react-bootstrap/Dropdown'
import ShareIcon from '../svgs/share-fill.svg'
import copy from 'clipboard-copy'
import { useMe } from './me'
import { useToast } from './toast'
export default function Share ({ item }) {
const me = useMe()
const toaster = useToast()
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
return typeof window !== 'undefined' && navigator?.share
@ -13,16 +15,17 @@ export default function Share ({ item }) {
<ShareIcon
width={20} height={20}
className='mx-2 fill-grey theme'
onClick={() => {
if (navigator.share) {
navigator.share({
onClick={async () => {
try {
await navigator.share({
title: item.title || '',
text: '',
url
}).then(() => console.log('Successful share'))
.catch((error) => console.log('Error sharing', error))
} else {
console.log('no navigator.share')
})
toaster.success('link shared')
} catch (err) {
console.error(err)
toaster.danger('failed to share link')
}
}}
/>
@ -36,7 +39,13 @@ export default function Share ({ item }) {
<Dropdown.Menu>
<Dropdown.Item
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
@ -47,19 +56,25 @@ export default function Share ({ item }) {
export function CopyLinkDropdownItem ({ item }) {
const me = useMe()
const toaster = useToast()
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
return (
<Dropdown.Item
onClick={async () => {
if (navigator.share) {
navigator.share({
title: item.title || '',
text: '',
url
}).then(() => console.log('Successful share'))
.catch((error) => console.log('Error sharing', error))
} else {
copy(url)
try {
if (navigator.share) {
await navigator.share({
title: item.title || '',
text: '',
url
})
} else {
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 { gql } from 'graphql-tag'
import Dropdown from 'react-bootstrap/Dropdown'
import { useToast } from './toast'
export default function SubscribeDropdownItem ({ item: { id, meSubscription } }) {
const toaster = useToast()
const [subscribeItem] = useMutation(
gql`
mutation subscribeItem($id: ID!) {
@ -22,7 +24,15 @@ export default function SubscribeDropdownItem ({ item: { id, meSubscription } })
)
return (
<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'}
</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 ErrorBoundary from '../components/error-boundary'
import { LightningProvider } from '../components/lightning'
import { ToastProvider } from '../components/toast'
import { ServiceWorkerProvider } from '../components/serviceworker'
import { SSR } from '../lib/constants'
import NProgress from 'nprogress'
@ -90,11 +91,13 @@ function MyApp ({ Component, pageProps: { ...props } }) {
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<PaymentTokenProvider>
<ShowModalProvider>
<Component ssrData={ssrData} {...otherProps} />
</ShowModalProvider>
</PaymentTokenProvider>
<ToastProvider>
<PaymentTokenProvider>
<ShowModalProvider>
<Component ssrData={ssrData} {...otherProps} />
</ShowModalProvider>
</PaymentTokenProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>

View File

@ -22,6 +22,7 @@ import PageLoading from '../components/page-loading'
import { useShowModal } from '../components/modal'
import { authErrorMessage } from '../components/login'
import { NostrAuth } from '../components/nostr-auth'
import { useToast } from '../components/toast'
export const getServerSideProps = getGetServerSideProps(SETTINGS)
@ -30,7 +31,7 @@ function bech32encode (hexString) {
}
export default function Settings ({ ssrData }) {
const [success, setSuccess] = useState()
const toaster = useToast()
const [setSettings] = useMutation(SET_SETTINGS, {
update (cache, { data: { setSettings } }) {
cache.modify({
@ -89,18 +90,22 @@ export default function Settings ({ ssrData }) {
const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0)
await setSettings({
variables: {
tipDefault: Number(tipDefault),
nostrPubkey,
nostrRelays: nostrRelaysFiltered,
...values
}
})
setSuccess('settings saved')
try {
await setSettings({
variables: {
tipDefault: Number(tipDefault),
nostrPubkey,
nostrRelays: nostrRelaysFiltered,
...values
}
})
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
label='zap default'
name='tipDefault'
@ -329,6 +334,7 @@ function NostrLinkButton ({ unlink, status }) {
function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
const router = useRouter()
const toaster = useToast()
return (
<div>
@ -344,9 +350,15 @@ function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
}}
schema={lastAuthRemovalSchema}
onSubmit={async () => {
await unlinkAuth({ variables: { authType: type } })
router.push('/settings')
onClose()
try {
await unlinkAuth({ variables: { authType: type } })
router.push('/settings')
onClose()
toaster.success('unlinked auth method')
} catch (err) {
console.error(err)
toaster.danger('failed to unlink auth method')
}
}}
>
<Input
@ -362,6 +374,7 @@ function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
function AuthMethods ({ methods }) {
const showModal = useShowModal()
const router = useRouter()
const toaster = useToast()
const [err, setErr] = useState(authErrorMessage(router.query.error))
const [unlinkAuth] = useMutation(
gql`
@ -396,7 +409,13 @@ function AuthMethods ({ methods }) {
if (links === 1) {
showModal(onClose => (<UnlinkObstacle onClose={onClose} type={type} unlinkAuth={unlinkAuth} />))
} 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-border-color: var(--theme-borderColor);
$grid-gutter-width: 2rem;
$toast-spacing: .5rem;
:root, [data-bs-theme=light] {
--theme-navLink: rgba(0, 0, 0, 0.55);
@ -153,6 +154,10 @@ $grid-gutter-width: 2rem;
height: 2px !important;
}
.toast-body {
padding: 0.75rem 1.25rem !important;
}
#nprogress .peg {
box-shadow: 0 0 10px var(--bs-primary), 0 0 5px var(--bs-primary) !important;
}