From 0ee056b2a196574b5402c78d5b7b32481030afb2 Mon Sep 17 00:00:00 2001 From: SatsAllDay <128755788+SatsAllDay@users.noreply.github.com> Date: Fri, 25 Aug 2023 19:21:51 -0400 Subject: [PATCH] Toast (#431) * 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 --- components/bookmark.js | 12 +++++- components/comment-edit.js | 2 +- components/delete.js | 8 +++- components/dont-link-this.js | 6 +++ components/form.js | 46 ++++++++++++-------- components/item-info.js | 2 +- components/share.js | 49 ++++++++++++++-------- components/subscribe.js | 12 +++++- components/toast.js | 81 ++++++++++++++++++++++++++++++++++++ components/toast.module.css | 26 ++++++++++++ pages/_app.js | 13 +++--- pages/settings.js | 49 +++++++++++++++------- styles/globals.scss | 5 +++ 13 files changed, 250 insertions(+), 61 deletions(-) create mode 100644 components/toast.js create mode 100644 components/toast.module.css diff --git a/components/bookmark.js b/components/bookmark.js index 71a6e33d..2951a4ac 100644 --- a/components/bookmark.js +++ b/components/bookmark.js @@ -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 ( 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'} diff --git a/components/comment-edit.js b/components/comment-edit.js index 1fac5c97..0f6e1077 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -51,7 +51,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc required />
- + { return ( { 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) } diff --git a/components/dont-link-this.js b/components/dont-link-this.js index dc3b10d7..c5e4b538 100644 --- a/components/dont-link-this.js +++ b/components/dont-link-this.js @@ -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 }) + } else { + toaster.danger('failed to flag item') } } }} diff --git a/components/form.js b/components/form.js index 767847c2..c2f27a6e 100644 --- a/components/form.js +++ b/components/form.js @@ -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 ( - 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?.()) + } + }} > - {error && setError(undefined)} dismissible>{error}} {children} diff --git a/components/item-info.js b/components/item-info.js index 7b2ac246..04e56c39 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -140,7 +140,7 @@ export default function ItemInfo ({ {me && !item.meSats && !item.position && !item.meDontLike && !item.mine && !item.deletedAt && } {item.mine && !item.position && !item.deletedAt && - } + } {extraInfo}
diff --git a/components/share.js b/components/share.js index 6b7e7827..ab32a431 100644 --- a/components/share.js +++ b/components/share.js @@ -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 }) { { - 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 }) { { - 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 ( { - 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') } }} > diff --git a/components/subscribe.js b/components/subscribe.js index bbded811..32023c85 100644 --- a/components/subscribe.js +++ b/components/subscribe.js @@ -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 ( 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'} diff --git a/components/toast.js b/components/toast.js new file mode 100644 index 00000000..24c31816 --- /dev/null +++ b/components/toast.js @@ -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 ( + + + {toasts.map(toast => ( + removeToast(toast.id)} + > + +
+
{toast.body}
+ +
+
+
+ ))} +
+ {children} +
+ ) +} + +export const useToast = () => useContext(ToastContext) diff --git a/components/toast.module.css b/components/toast.module.css new file mode 100644 index 00000000..fab1e1f6 --- /dev/null +++ b/components/toast.module.css @@ -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); + } +} diff --git a/pages/_app.js b/pages/_app.js index 4efedb03..c2a7a609 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -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 } }) { - - - - - + + + + + + + diff --git a/pages/settings.js b/pages/settings.js index 63320410..543b0306 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -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 && setSuccess(undefined)} dismissible>{success}} @@ -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') + } }} > ()) } 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') + } } } diff --git a/styles/globals.scss b/styles/globals.scss index c9909684..d3a72fea 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -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; }