From 94cce9155d9b279af3bf36563e38419a43215f95 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 28 May 2024 12:18:54 -0500 Subject: [PATCH] Frontend payment UX cleanup (#1194) * Replace useInvoiceable with usePayment hook * Show WebLnError in QR code fallback * Fix missing removal of old zap undo code * Fix payment timeout message * Fix unused arg in super() * Also bail if invoice expired * Fix revert on reply error * Use JIT_INVOICE_TIMEOUT_MS constant * Remove unnecessary PaymentContext * Fix me as a dependency in FeeButtonContext * Fix anon sats added before act success * Optimistic updates for zaps * Fix modal not closed after custom zap * Optimistic update for custom zaps * Optimistic update for bounty payments * Consistent error handling for zaps and bounty payments * Optimistic update for poll votes * Use var balance in payment.request * Rename invoiceable to prepaid * Log cancelled invoices * Client notifications We now show notifications that are stored on the client to inform the user about following errors in the prepaid payment flow: - if a payment fails - if an invoice expires before it is paid - if a payment was interrupted (for example via page refresh) - if the action fails after payment * Remove unnecessary passing of act * Use AbortController for zap undos * Fix anon zap update not updating bolt color * Fix zap counted towards anon sats even if logged in * Fix duplicate onComplete call * Fix downzap type error * Fix "missing field 'path' while writing result" error * Pass full item in downzap props The previous commit fixed cache updates for downzaps but then the cache update for custom zaps failed because 'path' wasn't included in the server response. This commit is the proper fix. * Parse lnc rpc error messages * Add hash to InvoiceExpiredError --- api/resolvers/item.js | 2 +- components/bounty-form.js | 3 +- components/client-notifications.js | 187 +++++++++++ components/comment.js | 2 +- components/discussion-form.js | 2 +- components/dont-link-this.js | 22 +- components/fee-button.js | 15 +- components/form.js | 86 +++-- components/invoice.js | 327 ++---------------- components/item-act.js | 495 ++++++++++++---------------- components/item-info.js | 2 +- components/item.js | 2 +- components/job-form.js | 3 +- components/link-form.js | 2 +- components/notifications.js | 23 +- components/pay-bounty.js | 63 +++- components/payment.js | 212 ++++++++++++ components/poll-form.js | 2 +- components/poll.js | 109 +++--- components/qr.js | 2 +- components/reply.js | 2 +- components/territory-form.js | 2 +- components/territory-payment-due.js | 2 +- components/toast.js | 127 +------ components/toast.module.css | 14 - components/upvote.js | 34 +- components/upvote.module.css | 16 +- components/webln/index.js | 40 +-- components/webln/lnc.js | 10 +- components/webln/nwc.js | 9 +- lib/apollo.js | 14 + lib/constants.js | 3 + pages/_app.js | 47 +-- pages/invoices/[id].js | 3 +- pages/rewards/index.js | 2 +- pages/settings/index.js | 4 +- 36 files changed, 980 insertions(+), 910 deletions(-) create mode 100644 components/client-notifications.js create mode 100644 components/payment.js diff --git a/api/resolvers/item.js b/api/resolvers/item.js index f502b08b..a5dfcf1c 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -899,7 +899,7 @@ export default { WHERE act IN ('TIP', 'FEE') AND "itemId" = ${Number(id)}::INTEGER AND "userId" = ${me.id}::INTEGER)::INTEGER)`, - { models } + { models, lnd, hash, hmac } ) } else { await serialize( diff --git a/components/bounty-form.js b/components/bounty-form.js index 4e60e591..fa101519 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -107,7 +107,8 @@ export function BountyForm ({ ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} - invoiceable={{ requireSession: true }} + requireSession + prepaid onSubmit={ handleSubmit || onSubmit diff --git a/components/client-notifications.js b/components/client-notifications.js new file mode 100644 index 00000000..2687b0ba --- /dev/null +++ b/components/client-notifications.js @@ -0,0 +1,187 @@ +import { useApolloClient } from '@apollo/client' +import { useMe } from './me' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { datePivot, timeSince } from '@/lib/time' +import { ANON_USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { HAS_NOTIFICATIONS } from '@/fragments/notifications' +import Item from './item' +import { RootProvider } from './root' +import Comment from './comment' + +const toType = t => ({ ERROR: `${t}_ERROR`, PENDING: `${t}_PENDING` }) + +export const Types = { + Zap: toType('ZAP'), + Reply: toType('REPLY'), + Bounty: toType('BOUNTY'), + PollVote: toType('POLL_VOTE') +} + +const ClientNotificationContext = createContext() + +export function ClientNotificationProvider ({ children }) { + const [notifications, setNotifications] = useState([]) + const client = useApolloClient() + const me = useMe() + // anons don't have access to /notifications + // but we'll store client notifications anyway for simplicity's sake + const storageKey = `client-notifications:${me?.id || ANON_USER_ID}` + + useEffect(() => { + const loaded = loadNotifications(storageKey, client) + setNotifications(loaded) + }, [storageKey]) + + const notify = useCallback((type, props) => { + const id = crypto.randomUUID() + const sortTime = new Date() + const expiresAt = +datePivot(sortTime, { milliseconds: JIT_INVOICE_TIMEOUT_MS }) + const isError = type.endsWith('ERROR') + const n = { __typename: type, id, sortTime: +sortTime, pending: !isError, expiresAt, ...props } + + setNotifications(notifications => [n, ...notifications]) + saveNotification(storageKey, n) + + if (isError) { + client?.writeQuery({ + query: HAS_NOTIFICATIONS, + data: { + hasNewNotes: true + } + }) + } + + return id + }, [storageKey, client]) + + const unnotify = useCallback((id) => { + setNotifications(notifications => notifications.filter(n => n.id !== id)) + removeNotification(storageKey, id) + }, [storageKey]) + + const value = useMemo(() => ({ notifications, notify, unnotify }), [notifications, notify, unnotify]) + return ( + + {children} + + ) +} + +export function ClientNotifyProvider ({ children, additionalProps }) { + const ctx = useClientNotifications() + + const notify = useCallback((type, props) => { + return ctx.notify(type, { ...props, ...additionalProps }) + }, [ctx.notify]) + + const value = useMemo(() => ({ ...ctx, notify }), [ctx, notify]) + return ( + + {children} + + ) +} + +export function useClientNotifications () { + return useContext(ClientNotificationContext) +} + +function ClientNotification ({ n, message }) { + if (n.pending) { + const expired = n.expiresAt < +new Date() + if (!expired) return null + n.reason = 'invoice expired' + } + + // remove payment hashes due to x-overflow + n.reason = n.reason.replace(/(: )?[a-f0-9]{64}/, '') + + return ( +
+ + {n.reason ? `${message}: ${n.reason}` : message} + {timeSince(new Date(n.sortTime))} + + {!n.item + ? null + : n.item.title + ? + : ( +
+ + + +
+ )} +
+ ) +} + +export function ClientZap ({ n }) { + const message = `failed to zap ${n.sats || n.amount} sats` + return +} + +export function ClientReply ({ n }) { + const message = 'failed to submit reply' + return +} + +export function ClientBounty ({ n }) { + const message = 'failed to pay bounty' + return +} + +export function ClientPollVote ({ n }) { + const message = 'failed to submit poll vote' + return +} + +function loadNotifications (storageKey, client) { + const stored = window.localStorage.getItem(storageKey) + if (!stored) return [] + + const filtered = JSON.parse(stored).filter(({ sortTime }) => { + // only keep notifications younger than 24 hours + return new Date(sortTime) >= datePivot(new Date(), { hours: -24 }) + }) + + let hasNewNotes = false + const mapped = filtered.map((n) => { + if (!n.pending) return n + // anything that is still pending when we load the page was interrupted + // so we immediately mark it as failed instead of waiting until it expired + const type = n.__typename.replace('PENDING', 'ERROR') + const reason = 'payment was interrupted' + hasNewNotes = true + return { ...n, __typename: type, pending: false, reason } + }) + + if (hasNewNotes) { + client?.writeQuery({ + query: HAS_NOTIFICATIONS, + data: { + hasNewNotes: true + } + }) + } + + window.localStorage.setItem(storageKey, JSON.stringify(mapped)) + return filtered +} + +function saveNotification (storageKey, n) { + const stored = window.localStorage.getItem(storageKey) + if (stored) { + window.localStorage.setItem(storageKey, JSON.stringify([...JSON.parse(stored), n])) + } else { + window.localStorage.setItem(storageKey, JSON.stringify([n])) + } +} + +function removeNotification (storageKey, id) { + const stored = window.localStorage.getItem(storageKey) + if (stored) { + window.localStorage.setItem(storageKey, JSON.stringify(JSON.parse(stored).filter(n => n.id !== id))) + } +} diff --git a/components/comment.js b/components/comment.js index eb1b3581..ac362e6b 100644 --- a/components/comment.js +++ b/components/comment.js @@ -145,7 +145,7 @@ export default function Comment ({ {item.outlawed && !me?.privates?.wildWestMode ? : item.meDontLikeSats > item.meSats - ? + ? : pin ? : }
diff --git a/components/discussion-form.js b/components/discussion-form.js index 62d08a47..ab431249 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -96,7 +96,7 @@ export function DiscussionForm ({ ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} - invoiceable + prepaid onSubmit={handleSubmit || onSubmit} storageKeyPrefix={storageKeyPrefix} > diff --git a/components/dont-link-this.js b/components/dont-link-this.js index 38574d9f..bb5cb761 100644 --- a/components/dont-link-this.js +++ b/components/dont-link-this.js @@ -1,15 +1,15 @@ import Dropdown from 'react-bootstrap/Dropdown' import { useShowModal } from './modal' import { useToast } from './toast' -import ItemAct, { zapUndosThresholdReached } from './item-act' +import ItemAct from './item-act' import AccordianItem from './accordian-item' import Flag from '@/svgs/flag-fill.svg' import { useMemo } from 'react' import getColor from '@/lib/rainbow' import { gql, useMutation } from '@apollo/client' -import { useMe } from './me' -export function DownZap ({ id, meDontLikeSats, ...props }) { +export function DownZap ({ item, ...props }) { + const { meDontLikeSats } = item const style = useMemo(() => (meDontLikeSats ? { fill: getColor(meDontLikeSats), @@ -17,14 +17,13 @@ export function DownZap ({ id, meDontLikeSats, ...props }) { } : undefined), [meDontLikeSats]) return ( - } /> + } /> ) } -function DownZapper ({ id, As, children }) { +function DownZapper ({ item, As, children }) { const toaster = useToast() const showModal = useShowModal() - const me = useMe() return ( { - onClose() - // undo prompt was toasted before closing modal if zap undos are enabled - // so an additional success toast would be confusing - if (!zapUndosThresholdReached(me, amount)) toaster.success('item downzapped') - }} itemId={id} down + onClose={onClose} item={item} down > downzap diff --git a/components/fee-button.js b/components/fee-button.js index fad5bf2c..7a600314 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -64,6 +64,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) { export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) { const [lineItems, setLineItems] = useState({}) const [disabled, setDisabled] = useState(false) + const me = useMe() const remoteLineItems = useRemoteLineItems() @@ -76,14 +77,18 @@ export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () const value = useMemo(() => { const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems } + const total = Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0) + // freebies: there's only a base cost and we don't have enough sats + const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total return { lines, merge: mergeLineItems, - total: Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0), + total, disabled, - setDisabled + setDisabled, + free } - }, [baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled]) + }, [me?.privates?.sats, baseLineItems, lineItems, remoteLineItems, mergeLineItems, disabled, setDisabled]) return ( @@ -111,9 +116,7 @@ function FreebieDialog () { export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) { const me = useMe() - const { lines, total, disabled: ctxDisabled } = useFeeButton() - // freebies: there's only a base cost and we don't have enough sats - const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total + const { lines, total, disabled: ctxDisabled, free } = useFeeButton() const feeText = free ? 'free' : total > 1 diff --git a/components/form.js b/components/form.js index 95f9135a..7b047f89 100644 --- a/components/form.js +++ b/components/form.js @@ -18,7 +18,6 @@ import { gql, useLazyQuery } from '@apollo/client' import { USER_SUGGESTIONS } from '@/fragments/users' import TextareaAutosize from 'react-textarea-autosize' import { useToast } from './toast' -import { useInvoiceable } from './invoice' import { numWithUnits } from '@/lib/format' import textAreaCaret from 'textarea-caret' import ReactDatePicker from 'react-datepicker' @@ -32,6 +31,18 @@ import Thumb from '@/svgs/thumb-up-fill.svg' import Eye from '@/svgs/eye-fill.svg' import EyeClose from '@/svgs/eye-close-line.svg' import Info from './info' +import { InvoiceCanceledError, usePayment } from './payment' +import { useMe } from './me' +import { optimisticUpdate } from '@/lib/apollo' +import { useClientNotifications } from './client-notifications' +import { ActCanceledError } from './item-act' + +export class SessionRequiredError extends Error { + constructor () { + super('session required') + this.name = 'SessionRequiredError' + } +} export function SubmitButton ({ children, variant, value, onClick, disabled, nonDisabledText, ...props @@ -793,11 +804,16 @@ const StorageKeyPrefixContext = createContext() export function Form ({ initial, schema, onSubmit, children, initialError, validateImmediately, - storageKeyPrefix, validateOnChange = true, invoiceable, innerRef, ...props + storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef, + optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props }) { const toaster = useToast() const initialErrorToasted = useRef(false) const feeButton = useFeeButton() + const payment = usePayment() + const me = useMe() + const { notify, unnotify } = useClientNotifications() + useEffect(() => { if (initialError && !initialErrorToasted.current) { toaster.danger('form error: ' + initialError.message || initialError.toString?.()) @@ -820,37 +836,57 @@ export function Form ({ }) }, [storageKeyPrefix]) - // if `invoiceable` is set, - // support for payment per invoice if they are lurking or don't have enough balance - // is added to submit handlers. - // submit handlers need to accept { satsReceived, hash, hmac } in their first argument - // and use them as variables in their GraphQL mutation - if (invoiceable && onSubmit) { - const options = typeof invoiceable === 'object' ? invoiceable : undefined - onSubmit = useInvoiceable(onSubmit, options) - } - - const onSubmitInner = useCallback(async (values, ...args) => { + const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => { + const variables = { amount, ...values } + let revert, cancel, nid try { if (onSubmit) { - // extract cost from formik fields - // (cost may also be set in a formik field named 'amount') - const cost = feeButton?.total || values?.amount - if (cost) { - values.cost = cost + if (requireSession && !me) { + throw new SessionRequiredError() } - await onSubmit(values, ...args) + + if (optimisticUpdateArgs) { + revert = optimisticUpdate(optimisticUpdateArgs(variables)) + } + + await signal?.pause({ me, amount }) + + if (me && clientNotification) { + nid = notify(clientNotification.PENDING, variables) + } + + let hash, hmac + if (prepaid) { + [{ hash, hmac }, cancel] = await payment.request(amount) + } + + await onSubmit({ hash, hmac, ...variables }, ...args) + if (!storageKeyPrefix) return clearLocalStorage(values) } } catch (err) { - const msg = err.message || err.toString?.() - // ignore errors from JIT invoices or payments from attached wallets - // that mean that submit failed because user aborted the payment - if (msg === 'modal closed' || msg === 'invoice canceled') return - toaster.danger('submit error: ' + msg) + revert?.() + + if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) { + return + } + + const reason = err.message || err.toString?.() + if (me && clientNotification) { + notify(clientNotification.ERROR, { ...variables, reason }) + } else { + toaster.danger('submit error: ' + reason) + } + + cancel?.() + } finally { + // if we reach this line, the submit either failed or was successful so we can remove the pending notification. + // if we don't reach this line, the page was probably reloaded and we can use the pending notification + // stored in localStorage to handle this case. + if (nid) unnotify(nid) } - }, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix]) + }, [me, onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix, payment, signal]) return ( {error.toString()}
+ } + // if webLn was not passed, use true by default if (webLn === undefined) webLn = true @@ -48,6 +60,11 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn } return ( <> + {webLnError && !(webLnError instanceof WebLnNotEnabledError) && +
+ Payment from attached wallet failed: +
{webLnError.toString()}
+
} ) } - -const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCancel, onRetry }) => { - const { data, loading, error } = useQuery(INVOICE, { - pollInterval: FAST_POLL_INTERVAL, - variables: { id } - }) - const [retryError, setRetryError] = useState(0) - if (error) { - if (error.message?.includes('invoice not found')) { - return - } - return
error
- } - if (!data || loading) { - return - } - - const retry = !!onRetry - let errorStatus = 'Something went wrong trying to perform the action after payment.' - if (retryError > 0) { - errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.' - } - - return ( - <> - - {retry - ? ( - <> -
- -
-
- - -
- - ) - : null} - - ) -} - -const defaultOptions = { - requireSession: false, - forceInvoice: false -} -export const useInvoiceable = (onSubmit, options = defaultOptions) => { - const me = useMe() - const [createInvoice] = useMutation(gql` - mutation createInvoice($amount: Int!) { - createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) { - id - bolt11 - hash - hmac - expiresAt - } - }`) - const [cancelInvoice] = useMutation(gql` - mutation cancelInvoice($hash: String!, $hmac: String!) { - cancelInvoice(hash: $hash, hmac: $hmac) { - id - } - } - `) - - const showModal = useShowModal() - const provider = useWebLN() - const client = useApolloClient() - const pollInvoice = (id) => client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } }) - - const onSubmitWrapper = useCallback(async ( - { cost, ...formValues }, - { variables, optimisticResponse, update, flowId, ...submitArgs }) => { - // some actions require a session - if (!me && options.requireSession) { - throw new Error('you must be logged in') - } - - // id for toast flows - if (!flowId) flowId = (+new Date()).toString(16) - - // educated guesses where action might pass in the invoice amount - // (field 'cost' has highest precedence) - cost ??= formValues.amount - - // attempt action for the first time - if (!cost || (me && !options.forceInvoice)) { - try { - const insufficientFunds = me?.privates.sats < cost - return await onSubmit(formValues, - { ...submitArgs, flowId, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse, update }) - } catch (error) { - if (!payOrLoginError(error) || !cost) { - // can't handle error here - bail - throw error - } - } - } - - // initial attempt of action failed. we will create an invoice, pay and retry now. - const { data, error } = await createInvoice({ variables: { amount: cost } }) - if (error) { - throw error - } - const inv = data.createInvoice - - // If this is a zap, we need to manually be optimistic to have a consistent - // UX across custodial and WebLN zaps since WebLN zaps don't call GraphQL - // mutations which implement optimistic responses natively. - // Therefore, we check if this is a zap and then wrap the WebLN payment logic - // with manual cache update calls. - const itemId = optimisticResponse?.act?.id - const isZap = !!itemId - let _update - if (isZap && update) { - _update = () => { - const fragment = { - id: `Item:${itemId}`, - fragment: gql` - fragment ItemMeSatsInvoice on Item { - sats - meSats - } - ` - } - const item = client.cache.readFragment(fragment) - update(client.cache, { data: optimisticResponse }) - // undo function - return () => client.cache.writeFragment({ ...fragment, data: item }) - } - } - - // wait until invoice is paid or modal is closed - const { modalOnClose, webLn, gqlCacheUpdateUndo } = await waitForPayment({ - invoice: inv, - showModal, - provider, - pollInvoice, - gqlCacheUpdate: _update, - flowId - }) - - const retry = () => onSubmit( - { hash: inv.hash, hmac: inv.hmac, expiresAt: inv.expiresAt, ...formValues }, - // unset update function since we already ran an cache update if we paid using WebLN - // also unset update function if null was explicitly passed in - { ...submitArgs, variables, update: webLn ? null : undefined }) - // first retry - try { - const ret = await retry() - modalOnClose?.() - return ret - } catch (error) { - gqlCacheUpdateUndo?.() - console.error('retry error:', error) - } - - // retry until success or cancel - return await new Promise((resolve, reject) => { - const cancelAndReject = async () => { - await cancelInvoice({ variables: { hash: inv.hash, hmac: inv.hmac } }) - reject(new Error('invoice canceled')) - } - showModal(onClose => { - return ( - { - await cancelAndReject() - onClose() - }} - onRetry={async () => { - resolve(await retry()) - onClose() - }} - /> - ) - }, { keepOpen: true, onClose: cancelAndReject }) - }) - }, [onSubmit, provider, createInvoice, !!me]) - - return onSubmitWrapper -} - -const INVOICE_CANCELED_ERROR = 'invoice canceled' -const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate, flowId }) => { - if (provider) { - try { - return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId }) - } catch (err) { - // check for errors which mean that QR code will also fail - if (err.message === INVOICE_CANCELED_ERROR) { - throw err - } - } - } - - // QR code as fallback - return await new Promise((resolve, reject) => { - showModal(onClose => { - return ( - resolve({ modalOnClose: onClose })} - /> - ) - }, { keepOpen: true, onClose: reject }) - }) -} - -const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId }) => { - let undoUpdate - try { - // try WebLN provider first - return await new Promise((resolve, reject) => { - // be optimistic and pretend zap was already successful for consistent zapping UX - undoUpdate = gqlCacheUpdate?.() - // can't use await here since we might be paying JIT invoices - // and sendPaymentAsync is not supported yet. - // see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync - provider.sendPayment({ ...invoice, flowId }) - // WebLN payment will never resolve here for JIT invoices - // since they only get resolved after settlement which can't happen here - .then(() => resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate })) - .catch(err => { - clearInterval(interval) - reject(err) - }) - const interval = setInterval(async () => { - try { - const { data, error } = await pollInvoice(invoice.id) - if (error) { - clearInterval(interval) - return reject(error) - } - const { invoice: inv } = data - if (inv.isHeld && inv.satsReceived) { - clearInterval(interval) - resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate }) - } - if (inv.cancelled) { - clearInterval(interval) - reject(new Error(INVOICE_CANCELED_ERROR)) - } - } catch (err) { - clearInterval(interval) - reject(err) - } - }, FAST_POLL_INTERVAL) - }) - } catch (err) { - undoUpdate?.() - console.error('WebLN payment failed:', err) - throw err - } -} - -export const useInvoiceModal = (onPayment, deps) => { - const onPaymentMemo = useCallback(onPayment, deps) - return useInvoiceable(onPaymentMemo, { replaceModal: true }) -} - -export const payOrLoginError = (error) => { - const matches = ['insufficient funds', 'you must be logged in or pay'] - if (Array.isArray(error)) { - return error.some(({ message }) => matches.some(m => message.includes(m))) - } - return matches.some(m => error.toString().includes(m)) -} diff --git a/components/item-act.js b/components/item-act.js index 39dd2f08..29babfd4 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -5,13 +5,16 @@ import { Form, Input, SubmitButton } from './form' import { useMe } from './me' import UpBolt from '@/svgs/bolt.svg' import { amountSchema } from '@/lib/validate' -import { gql, useApolloClient, useMutation } from '@apollo/client' -import { payOrLoginError, useInvoiceModal } from './invoice' -import { TOAST_DEFAULT_DELAY_MS, useToast, withToastFlow } from './toast' +import { gql, useMutation } from '@apollo/client' +import { useToast } from './toast' import { useLightning } from './lightning' import { nextTip } from './upvote' +import { InvoiceCanceledError, usePayment } from './payment' +import { optimisticUpdate } from '@/lib/apollo' +import { Types as ClientNotification, ClientNotifyProvider, useClientNotifications } from './client-notifications' +import { ZAP_UNDO_DELAY_MS } from '@/lib/constants' -const defaultTips = [100, 1000, 10000, 100000] +const defaultTips = [100, 1000, 10_000, 100_000] const Tips = ({ setOValue }) => { const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b) @@ -41,222 +44,149 @@ const addCustomTip = (amount) => { window.localStorage.setItem('custom-tips', JSON.stringify(customTips)) } -export const zapUndosThresholdReached = (me, amount) => { - if (!me) return false - const enabled = me.privates.zapUndos !== null - return enabled ? amount >= me.privates.zapUndos : false +const setItemMeAnonSats = ({ id, amount }) => { + const storageKey = `TIP-item:${id}` + const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') + window.localStorage.setItem(storageKey, existingAmount + amount) } -export default function ItemAct ({ onClose, itemId, down, children }) { +export const actUpdate = ({ me, onUpdate }) => (cache, args) => { + const { data: { act: { id, sats, path, act } } } = args + + cache.modify({ + id: `Item:${id}`, + fields: { + sats (existingSats = 0) { + if (act === 'TIP') { + return existingSats + sats + } + + return existingSats + }, + meSats: (existingSats = 0) => { + if (act === 'TIP') { + return existingSats + sats + } + + return existingSats + }, + meDontLikeSats: me + ? (existingSats = 0) => { + if (act === 'DONT_LIKE_THIS') { + return existingSats + sats + } + + return existingSats + } + : undefined + } + }) + + if (act === 'TIP') { + // update all ancestors + path.split('.').forEach(aId => { + if (Number(aId) === Number(id)) return + cache.modify({ + id: `Item:${aId}`, + fields: { + commentSats (existingCommentSats = 0) { + return existingCommentSats + sats + } + } + }) + }) + } + + onUpdate?.(cache, args) +} + +export default function ItemAct ({ onClose, item, down, children, abortSignal }) { const inputRef = useRef(null) const me = useMe() const [oValue, setOValue] = useState() const strike = useLightning() - const toaster = useToast() - const client = useApolloClient() useEffect(() => { inputRef.current?.focus() - }, [onClose, itemId]) + }, [onClose, item.id]) - const [act, actUpdate] = useAct() + const act = useAct() - const onSubmit = useCallback(async ({ amount, hash, hmac }, { update, keepOpen }) => { - if (!me) { - const storageKey = `TIP-item:${itemId}` - const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') - window.localStorage.setItem(storageKey, existingAmount + amount) - } + const onSubmit = useCallback(async ({ amount, hash, hmac }) => { await act({ variables: { - id: itemId, + id: item.id, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP', hash, hmac - }, - update + } }) - // only strike when zap undos not enabled - // due to optimistic UX on zap undos - if (!zapUndosThresholdReached(me, Number(amount))) await strike() + if (!me) setItemMeAnonSats({ id: item.id, amount }) addCustomTip(Number(amount)) - if (!keepOpen) onClose(Number(amount)) - }, [me, act, down, itemId, strike]) + }, [me, act, down, item.id, strike]) - const onSubmitWithUndos = withToastFlow(toaster)( - (values, args) => { - const { flowId } = args - let canceled - const sats = values.amount - const insufficientFunds = me?.privates?.sats < sats - const invoiceAttached = values.hash && values.hmac - if (insufficientFunds && !invoiceAttached) throw new Error('insufficient funds') - // payments from external wallets already have their own flow - // and we don't want to show undo toasts for them - const skipToastFlow = invoiceAttached - // update function for optimistic UX - const update = () => { - const fragment = { - id: `Item:${itemId}`, - fragment: gql` - fragment ItemMeSatsSubmit on Item { - path - sats - meSats - meDontLikeSats - } - ` - } - const item = client.cache.readFragment(fragment) - const optimisticResponse = { - act: { - id: itemId, sats, path: item.path, act: down ? 'DONT_LIKE_THIS' : 'TIP' - } - } - actUpdate(client.cache, { data: optimisticResponse }) - return () => client.cache.writeFragment({ ...fragment, data: item }) - } - let undoUpdate - return { - skipToastFlow, - flowId, - tag: itemId, - type: 'zap', - pendingMessage: `${down ? 'down' : ''}zapped ${sats} sats`, - onPending: async () => { - if (skipToastFlow) { - return onSubmit(values, { flowId, ...args, update: null }) - } - await strike() - onClose(sats) - return new Promise((resolve, reject) => { - undoUpdate = update() - setTimeout(() => { - if (canceled) return resolve() - onSubmit(values, { flowId, ...args, update: null, keepOpen: true }) - .then(resolve) - .catch((err) => { - undoUpdate() - reject(err) - }) - }, TOAST_DEFAULT_DELAY_MS) - }) - }, - onUndo: () => { - canceled = true - undoUpdate?.() - }, - hideSuccess: true, - hideError: true, - timeout: TOAST_DEFAULT_DELAY_MS - } + const optimisticUpdate = useCallback(({ amount }) => { + const variables = { + id: item.id, + sats: Number(amount), + act: down ? 'DONT_LIKE_THIS' : 'TIP' } - ) + const optimisticResponse = { act: { ...variables, path: item.path } } + strike() + onClose() + return { mutation: ACT_MUTATION, variables, optimisticResponse, update: actUpdate({ me }) } + }, [item.id, down, !!me, strike]) return ( -
{ - if (zapUndosThresholdReached(me, values.amount)) { - return onSubmitWithUndos(values, ...args) - } - return onSubmit(values, ...args) - }} - > - sats} - /> -
- -
- {children} -
- {down && 'down'}zap -
-
+ +
+ sats} + /> +
+ +
+ {children} +
+ {down && 'down'}zap +
+
+
) } -export function useAct ({ onUpdate } = {}) { - const me = useMe() - - const update = useCallback((cache, args) => { - const { data: { act: { id, sats, path, act } } } = args - - cache.modify({ - id: `Item:${id}`, - fields: { - sats (existingSats = 0) { - if (act === 'TIP') { - return existingSats + sats - } - - return existingSats - }, - meSats: me - ? (existingSats = 0) => { - if (act === 'TIP') { - return existingSats + sats - } - - return existingSats - } - : undefined, - meDontLikeSats: me - ? (existingSats = 0) => { - if (act === 'DONT_LIKE_THIS') { - return existingSats + sats - } - - return existingSats - } - : undefined - } - }) - - if (act === 'TIP') { - // update all ancestors - path.split('.').forEach(aId => { - if (Number(aId) === Number(id)) return - cache.modify({ - id: `Item:${aId}`, - fields: { - commentSats (existingCommentSats = 0) { - return existingCommentSats + sats - } - } - }) - }) - - onUpdate && onUpdate(cache, args) +export const ACT_MUTATION = gql` + mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) { + act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) { + id + sats + path + act } - }, [!!me, onUpdate]) + }` - const [act] = useMutation( - gql` - mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) { - act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) { - id - sats - path - act - } - }`, { update } - ) - return [act, update] +export function useAct ({ onUpdate } = {}) { + const [act] = useMutation(ACT_MUTATION) + return act } export function useZap () { @@ -307,118 +237,107 @@ export function useZap () { } }, []) - const [zap] = useMutation( - gql` - mutation idempotentAct($id: ID!, $sats: Int!) { - act(id: $id, sats: $sats, idempotent: true) { - id - sats - path - } - }` - ) + const ZAP_MUTATION = gql` + mutation idempotentAct($id: ID!, $sats: Int!, $hash: String, $hmac: String) { + act(id: $id, sats: $sats, hash: $hash, hmac: $hmac, idempotent: true) { + id + sats + path + } + }` + const [zap] = useMutation(ZAP_MUTATION) + const me = useMe() + const { notify, unnotify } = useClientNotifications() const toaster = useToast() const strike = useLightning() - const [act] = useAct() - const client = useApolloClient() + const payment = usePayment() - const invoiceableAct = useInvoiceModal( - async ({ hash, hmac }, { variables, ...apolloArgs }) => { - await act({ variables: { ...variables, hash, hmac }, ...apolloArgs }) - strike() - }, [act, strike]) - - const zapWithUndos = withToastFlow(toaster)( - ({ variables, optimisticResponse, update, flowId }) => { - const { id: itemId, amount } = variables - let canceled - // update function for optimistic UX - const _update = () => { - const fragment = { - id: `Item:${itemId}`, - fragment: gql` - fragment ItemMeSatsUndos on Item { - sats - meSats - } - ` - } - const item = client.cache.readFragment(fragment) - update(client.cache, { data: optimisticResponse }) - // undo function - return () => client.cache.writeFragment({ ...fragment, data: item }) - } - let undoUpdate - return { - flowId, - tag: itemId, - type: 'zap', - pendingMessage: `zapped ${amount} sats`, - onPending: () => - new Promise((resolve, reject) => { - undoUpdate = _update() - setTimeout( - () => { - if (canceled) return resolve() - zap({ variables, optimisticResponse, update: null }).then(resolve).catch((err) => { - undoUpdate() - reject(err) - }) - }, - TOAST_DEFAULT_DELAY_MS - ) - }), - onUndo: () => { - // we can't simply clear the timeout on cancel since - // the onPending promise would never settle in that case - canceled = true - undoUpdate?.() - }, - hideSuccess: true, - hideError: true, - timeout: TOAST_DEFAULT_DELAY_MS - } - } - ) - - return useCallback(async ({ item, me }) => { + return useCallback(async ({ item, mem, abortSignal }) => { const meSats = (item?.meSats || 0) // add current sats to next tip since idempotent zaps use desired total zap not difference const sats = meSats + nextTip(meSats, { ...me?.privates }) - const amount = sats - meSats + const satsDelta = sats - meSats - const variables = { id: item.id, sats, act: 'TIP', amount } - const insufficientFunds = me?.privates.sats < amount + const variables = { id: item.id, sats, act: 'TIP' } + const notifyProps = { itemId: item.id, sats: satsDelta } const optimisticResponse = { act: { path: item.path, ...variables } } - const flowId = (+new Date()).toString(16) - const zapArgs = { variables, optimisticResponse: insufficientFunds ? null : optimisticResponse, update, flowId } + + let revert, cancel, nid try { - if (insufficientFunds) throw new Error('insufficient funds') + revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update }) strike() - if (zapUndosThresholdReached(me, amount)) { - await zapWithUndos(zapArgs) - } else { - await zap(zapArgs) + + await abortSignal.pause({ me, amount: satsDelta }) + + if (me) { + nid = notify(ClientNotification.Zap.PENDING, notifyProps) } + + let hash, hmac; + [{ hash, hmac }, cancel] = await payment.request(satsDelta) + await zap({ variables: { ...variables, hash, hmac } }) } catch (error) { - if (payOrLoginError(error)) { - // call non-idempotent version - const amount = sats - meSats - optimisticResponse.act.amount = amount - try { - await invoiceableAct({ amount }, { - variables: { ...variables, sats: amount }, - optimisticResponse, - update, - flowId - }) - } catch (error) {} + revert?.() + + if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) { return } - console.error(error) - toaster.danger('zap: ' + error?.message || error?.toString?.()) + + const reason = error?.message || error?.toString?.() + if (me) { + notify(ClientNotification.Zap.ERROR, { ...notifyProps, reason }) + } else { + toaster.danger('zap failed: ' + reason) + } + + cancel?.() + } finally { + if (nid) unnotify(nid) } + }, [me?.id, strike, payment, notify, unnotify]) +} + +export class ActCanceledError extends Error { + constructor () { + super('act canceled') + this.name = 'ActCanceledError' + } +} + +export class ZapUndoController extends AbortController { + constructor () { + super() + this.signal.start = () => { this.started = true } + this.signal.done = () => { this.done = true } + this.signal.pause = async ({ me, amount }) => { + if (zapUndoTrigger({ me, amount })) { + await zapUndo(this.signal) + } + } + } +} + +const zapUndoTrigger = ({ me, amount }) => { + if (!me) return false + const enabled = me.privates.zapUndos !== null + return enabled ? amount >= me.privates.zapUndos : false +} + +const zapUndo = async (signal) => { + return await new Promise((resolve, reject) => { + signal.start() + const abortHandler = () => { + reject(new ActCanceledError()) + signal.done() + signal.removeEventListener('abort', abortHandler) + } + signal.addEventListener('abort', abortHandler) + setTimeout(() => { + resolve() + signal.done() + signal.removeEventListener('abort', abortHandler) + }, ZAP_UNDO_DELAY_MS) }) } diff --git a/components/item-info.js b/components/item-info.js index 0ca302f4..234d24ad 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -176,7 +176,7 @@ export default function ItemInfo ({ !item.mine && !item.deletedAt && (item.meDontLikeSats > meTotalSats ? - : )} + : )} {me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated && <>
diff --git a/components/item.js b/components/item.js index ea801fcf..cd0316e3 100644 --- a/components/item.js +++ b/components/item.js @@ -63,7 +63,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s {item.position && (pinnable || !item.subName) ? : item.meDontLikeSats > item.meSats - ? + ? : Number(item.user?.id) === AD_USER_ID ? : } diff --git a/components/job-form.js b/components/job-form.js index af45e375..847111bd 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -105,7 +105,8 @@ export default function JobForm ({ item, sub }) { }} schema={jobSchema} storageKeyPrefix={storageKeyPrefix} - invoiceable={{ requireSession: true }} + requireSession + prepaid onSubmit={onSubmit} >
diff --git a/components/link-form.js b/components/link-form.js index e2a3b9b8..8cea3e6f 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -143,7 +143,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) { ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} - invoiceable + prepaid onSubmit={onSubmit} storageKeyPrefix={storageKeyPrefix} > diff --git a/components/notifications.js b/components/notifications.js index 24045f2c..b346b4aa 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -30,12 +30,19 @@ import { nextBillingWithGrace } from '@/lib/territory' import { commentSubTreeRootId } from '@/lib/item' import LinkToContext from './link-to-context' import { Badge } from 'react-bootstrap' +import { Types as ClientTypes, ClientZap, ClientReply, ClientPollVote, ClientBounty, useClientNotifications } from './client-notifications' +import { ITEM_FULL } from '@/fragments/items' function Notification ({ n, fresh }) { const type = n.__typename + // we need to resolve item id to item to show item for client notifications + const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId }) + const item = data?.item + const itemN = { item, ...n } + return ( - + { (type === 'Earn' && ) || (type === 'Revenue' && ) || @@ -53,7 +60,11 @@ function Notification ({ n, fresh }) { (type === 'FollowActivity' && ) || (type === 'TerritoryPost' && ) || (type === 'TerritoryTransfer' && ) || - (type === 'Reminder' && ) + (type === 'Reminder' && ) || + ([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && ) || + ([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(type) && ) || + ([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(type) && ) || + ([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(type) && ) } ) @@ -102,6 +113,8 @@ const defaultOnClick = n => { if (type === 'Streak') return {} if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` } + if (!n.item) return {} + // Votification, Mention, JobChanged, Reply all have item if (!n.item.title) { const rootId = commentSubTreeRootId(n.item) @@ -534,6 +547,7 @@ export default function Notifications ({ ssrData }) { const { data, fetchMore } = useQuery(NOTIFICATIONS) const router = useRouter() const dat = useData(data, ssrData) + const { notifications: clientNotifications } = useClientNotifications() const { notifications, lastChecked, cursor } = useMemo(() => { if (!dat?.notifications) return {} @@ -561,9 +575,12 @@ export default function Notifications ({ ssrData }) { if (!dat) return + const sorted = [...clientNotifications, ...notifications] + .sort((a, b) => new Date(b.sortTime).getTime() - new Date(a.sortTime).getTime()) + return ( <> - {notifications.map(n => + {sorted.map(n => new Date(router?.query?.checkedAt ?? lastChecked)} diff --git a/components/pay-bounty.js b/components/pay-bounty.js index cd03c0a9..70accd8d 100644 --- a/components/pay-bounty.js +++ b/components/pay-bounty.js @@ -6,15 +6,23 @@ import { useMe } from './me' import { numWithUnits } from '@/lib/format' import { useShowModal } from './modal' import { useRoot } from './root' -import { payOrLoginError, useInvoiceModal } from './invoice' -import { useAct } from './item-act' +import { useAct, actUpdate, ACT_MUTATION } from './item-act' +import { InvoiceCanceledError, usePayment } from './payment' +import { optimisticUpdate } from '@/lib/apollo' +import { useLightning } from './lightning' +import { useToast } from './toast' +import { Types as ClientNotification, useClientNotifications } from './client-notifications' export default function PayBounty ({ children, item }) { const me = useMe() const showModal = useShowModal() const root = useRoot() + const payment = usePayment() + const strike = useLightning() + const toaster = useToast() + const { notify, unnotify } = useClientNotifications() - const onUpdate = useCallback((cache, { data: { act: { id, path } } }) => { + const onUpdate = useCallback(onComplete => (cache, { data: { act: { id, path } } }) => { // update root bounty status const root = path.split('.')[0] cache.modify({ @@ -25,30 +33,55 @@ export default function PayBounty ({ children, item }) { } } }) - }, []) + strike() + onComplete() + }, [strike]) - const [act] = useAct({ onUpdate }) - - const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => { - await act({ variables: { ...variables, hash, hmac } }) - }, [act]) + const act = useAct() const handlePayBounty = async onComplete => { - const variables = { id: item.id, sats: root.bounty, act: 'TIP', path: item.path } + const sats = root.bounty + const variables = { id: item.id, sats, act: 'TIP', path: item.path } + const notifyProps = { itemId: item.id, sats } + const optimisticResponse = { act: { ...variables, path: item.path } } + + let revert, cancel, nid try { - await act({ + revert = optimisticUpdate({ + mutation: ACT_MUTATION, variables, + optimisticResponse, + update: actUpdate({ me, onUpdate: onUpdate(onComplete) }) + }) + + if (me) { + nid = notify(ClientNotification.Bounty.PENDING, notifyProps) + } + + let hash, hmac; + [{ hash, hmac }, cancel] = await payment.request(sats) + await act({ + variables: { hash, hmac, ...variables }, optimisticResponse: { act: variables } }) - onComplete() } catch (error) { - if (payOrLoginError(error)) { - showInvoiceModal({ amount: root.bounty }, { variables }) + revert?.() + + if (error instanceof InvoiceCanceledError) { return } - throw new Error({ message: error.toString() }) + + const reason = error?.message || error?.toString?.() + if (me) { + notify(ClientNotification.Bounty.ERROR, { ...notifyProps, reason }) + } else { + toaster.danger('pay bounty failed: ' + reason) + } + cancel?.() + } finally { + if (nid) unnotify(nid) } } diff --git a/components/payment.js b/components/payment.js new file mode 100644 index 00000000..f1b1de37 --- /dev/null +++ b/components/payment.js @@ -0,0 +1,212 @@ +import { useCallback } from 'react' +import { useMe } from './me' +import { gql, useApolloClient, useMutation } from '@apollo/client' +import { useWebLN } from './webln' +import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { INVOICE } from '@/fragments/wallet' +import Invoice from '@/components/invoice' +import { useFeeButton } from './fee-button' +import { useShowModal } from './modal' + +export class InvoiceCanceledError extends Error { + constructor (hash) { + super(`invoice canceled: ${hash}`) + this.name = 'InvoiceCanceledError' + } +} + +export class WebLnNotEnabledError extends Error { + constructor () { + super('no enabled WebLN provider found') + this.name = 'WebLnNotEnabledError' + } +} + +export class InvoiceExpiredError extends Error { + constructor (hash) { + super(`invoice expired: ${hash}`) + this.name = 'InvoiceExpiredError' + } +} + +const useInvoice = () => { + const client = useApolloClient() + + const [createInvoice] = useMutation(gql` + mutation createInvoice($amount: Int!, $expireSecs: Int!) { + createInvoice(amount: $amount, hodlInvoice: true, expireSecs: $expireSecs) { + id + bolt11 + hash + hmac + expiresAt + satsRequested + } + }`) + const [cancelInvoice] = useMutation(gql` + mutation cancelInvoice($hash: String!, $hmac: String!) { + cancelInvoice(hash: $hash, hmac: $hmac) { + id + } + } + `) + + const create = useCallback(async amount => { + const { data, error } = await createInvoice({ variables: { amount, expireSecs: JIT_INVOICE_TIMEOUT_MS / 1000 } }) + if (error) { + throw error + } + const invoice = data.createInvoice + return invoice + }, [createInvoice]) + + const isPaid = useCallback(async id => { + const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } }) + if (error) { + throw error + } + const { hash, isHeld, satsReceived, cancelled } = data.invoice + // if we're polling for invoices, we're using JIT invoices so isHeld must be set + if (isHeld && satsReceived) { + return true + } + if (cancelled) { + throw new InvoiceCanceledError(hash) + } + return false + }, [client]) + + const waitUntilPaid = useCallback(async id => { + return await new Promise((resolve, reject) => { + const interval = setInterval(async () => { + try { + const paid = await isPaid(id) + if (paid) { + resolve() + clearInterval(interval) + } + } catch (err) { + reject(err) + clearInterval(interval) + } + }, FAST_POLL_INTERVAL) + }) + }, [isPaid]) + + const cancel = useCallback(async ({ hash, hmac }) => { + const inv = await cancelInvoice({ variables: { hash, hmac } }) + console.log('invoice canceled:', hash) + return inv + }, [cancelInvoice]) + + return { create, isPaid, waitUntilPaid, cancel } +} + +const useWebLnPayment = () => { + const invoice = useInvoice() + const provider = useWebLN() + + const waitForWebLnPayment = useCallback(async ({ id, bolt11 }) => { + if (!provider) { + throw new WebLnNotEnabledError() + } + try { + return await new Promise((resolve, reject) => { + // can't use await here since we might pay JIT invoices and sendPaymentAsync is not supported yet. + // see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync + provider.sendPayment(bolt11) + // JIT invoice payments will never resolve here + // since they only get resolved after settlement which can't happen here + .then(resolve) + .catch(reject) + invoice.waitUntilPaid(id) + .then(resolve) + .catch(reject) + }) + } catch (err) { + console.error('WebLN payment failed:', err) + throw err + } + }, [provider, invoice]) + + return waitForWebLnPayment +} + +const useQrPayment = () => { + const invoice = useInvoice() + const showModal = useShowModal() + + const waitForQrPayment = useCallback(async (inv, webLnError) => { + return await new Promise((resolve, reject) => { + let paid + const cancelAndReject = async (onClose) => { + if (paid) return + await invoice.cancel(inv) + reject(new InvoiceCanceledError(inv.hash)) + } + showModal(onClose => + { paid = true; onClose(); resolve() }} + poll + />, + { keepOpen: true, onClose: cancelAndReject }) + }) + }, [invoice]) + + return waitForQrPayment +} + +export const usePayment = () => { + const me = useMe() + const feeButton = useFeeButton() + const invoice = useInvoice() + const waitForWebLnPayment = useWebLnPayment() + const waitForQrPayment = useQrPayment() + + const waitForPayment = useCallback(async (invoice) => { + let webLnError + try { + return await waitForWebLnPayment(invoice) + } catch (err) { + if (err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) { + // bail since qr code payment will also fail + throw err + } + webLnError = err + } + return await waitForQrPayment(invoice, webLnError) + }, [waitForWebLnPayment, waitForQrPayment]) + + const request = useCallback(async (amount) => { + amount ??= feeButton?.total + const free = feeButton?.free + const balance = me ? me.privates.sats : 0 + + // if user has enough funds in their custodial wallet or action is free, never prompt for payment + // XXX this will probably not work as intended for deposits < balance + // which means you can't always fund your custodial wallet with attached wallets ... + // but should this even be the case? + const insufficientFunds = balance < amount + if (free || !insufficientFunds) return [{ hash: null, hmac: null }, null] + + const inv = await invoice.create(amount) + + await waitForPayment(inv) + + const cancel = () => invoice.cancel(inv).catch(console.error) + return [inv, cancel] + }, [me, feeButton?.total, invoice, waitForPayment]) + + const cancel = useCallback(({ hash, hmac }) => { + if (hash && hmac) { + invoice.cancel({ hash, hmac }).catch(console.error) + } + }, [invoice]) + + return { request, cancel } +} diff --git a/components/poll-form.js b/components/poll-form.js index decdcf37..71844df9 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -86,7 +86,7 @@ export function PollForm ({ item, sub, editThreshold, children }) { ...SubSelectInitial({ sub: item?.subName || sub?.name }) }} schema={schema} - invoiceable + prepaid onSubmit={onSubmit} storageKeyPrefix={storageKeyPrefix} > diff --git a/components/poll.js b/components/poll.js index c7765e48..5e10b771 100644 --- a/components/poll.js +++ b/components/poll.js @@ -8,68 +8,85 @@ import Check from '@/svgs/checkbox-circle-fill.svg' import { signIn } from 'next-auth/react' import ActionTooltip from './action-tooltip' import { POLL_COST } from '@/lib/constants' -import { payOrLoginError, useInvoiceModal } from './invoice' +import { InvoiceCanceledError, usePayment } from './payment' +import { optimisticUpdate } from '@/lib/apollo' +import { useToast } from './toast' +import { Types as ClientNotification, useClientNotifications } from './client-notifications' export default function Poll ({ item }) { const me = useMe() - const [pollVote] = useMutation( - gql` - mutation pollVote($id: ID!, $hash: String, $hmac: String) { - pollVote(id: $id, hash: $hash, hmac: $hmac) - }`, { - update (cache, { data: { pollVote } }) { - cache.modify({ - id: `Item:${item.id}`, - fields: { - poll (existingPoll) { - const poll = { ...existingPoll } - poll.meVoted = true - poll.count += 1 - return poll - } - } - }) - cache.modify({ - id: `PollOption:${pollVote}`, - fields: { - count (existingCount) { - return existingCount + 1 - }, - meVoted () { - return true - } - } - }) + const POLL_VOTE_MUTATION = gql` + mutation pollVote($id: ID!, $hash: String, $hmac: String) { + pollVote(id: $id, hash: $hash, hmac: $hmac) + }` + const [pollVote] = useMutation(POLL_VOTE_MUTATION) + const toaster = useToast() + const { notify, unnotify } = useClientNotifications() + + const update = (cache, { data: { pollVote } }) => { + cache.modify({ + id: `Item:${item.id}`, + fields: { + poll (existingPoll) { + const poll = { ...existingPoll } + poll.meVoted = true + poll.count += 1 + return poll + } } - } - ) + }) + cache.modify({ + id: `PollOption:${pollVote}`, + fields: { + count (existingCount) { + return existingCount + 1 + }, + meVoted () { + return true + } + } + }) + } const PollButton = ({ v }) => { - const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => { - await pollVote({ variables: { ...variables, hash, hmac } }) - }, [pollVote]) - - const variables = { id: v.id } - + const payment = usePayment() return (
@@ -196,78 +160,3 @@ export const ToastProvider = ({ children }) => { } export const useToast = () => useContext(ToastContext) - -export const withToastFlow = (toaster) => flowFn => { - const wrapper = async (...args) => { - const { - flowId, - type: t, - onPending, - pendingMessage, - onSuccess, - onCancel, - onError, - onUndo, - hideError, - hideSuccess, - skipToastFlow, - timeout, - ...toastProps - } = flowFn(...args) - let canceled - - if (skipToastFlow) return onPending() - - toaster.warning(pendingMessage || `${t} pending`, { - progressBar: !!timeout, - delay: timeout || TOAST_DEFAULT_DELAY_MS, - onCancel: onCancel - ? async () => { - try { - await onCancel() - canceled = true - toaster.warning(`${t} canceled`, { ...toastProps, flowId }) - } catch (err) { - toaster.danger(`failed to cancel ${t}`, { ...toastProps, flowId }) - } - } - : undefined, - onUndo: onUndo - ? async () => { - try { - await onUndo() - canceled = true - } catch (err) { - toaster.danger(`failed to undo ${t}`, { ...toastProps, flowId }) - } - } - : undefined, - flowId, - ...toastProps - }) - try { - const ret = await onPending() - if (!canceled) { - if (hideSuccess) { - toaster.endFlow(flowId) - } else { - toaster.success(`${t} successful`, { ...toastProps, flowId }) - } - await onSuccess?.() - } - return ret - } catch (err) { - // ignore errors if canceled since they might be caused by cancellation - if (canceled) return - const reason = err?.message?.toString().toLowerCase() || 'unknown reason' - if (hideError) { - toaster.endFlow(flowId) - } else { - toaster.danger(`${t} failed: ${reason}`, { ...toastProps, flowId }) - } - await onError?.() - throw err - } - } - return wrapper -} diff --git a/components/toast.module.css b/components/toast.module.css index 6811bc8b..2223f7cf 100644 --- a/components/toast.module.css +++ b/components/toast.module.css @@ -21,20 +21,6 @@ border-color: var(--bs-warning-border-subtle); } -.toastUndo { - font-style: normal; - cursor: pointer; - display: flex; - align-items: center; -} - -.toastCancel { - font-style: italic; - cursor: pointer; - display: flex; - align-items: center; -} - .toastClose { color: #fff; font-family: "lightning"; diff --git a/components/upvote.js b/components/upvote.js index 77b9fb5c..7543558b 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -2,7 +2,7 @@ import UpBolt from '@/svgs/bolt.svg' import styles from './upvote.module.css' import { gql, useMutation } from '@apollo/client' import ActionTooltip from './action-tooltip' -import ItemAct, { useAct, useZap } from './item-act' +import ItemAct, { ZapUndoController, useZap } from './item-act' import { useMe } from './me' import getColor from '@/lib/rainbow' import { useCallback, useMemo, useRef, useState } from 'react' @@ -59,7 +59,7 @@ export function DropdownItemUpVote ({ item }) { { showModal(onClose => - ) + ) }} > zap @@ -97,6 +97,9 @@ export default function UpVote ({ item, className }) { }` ) + const [controller, setController] = useState(null) + const pending = controller?.started && !controller.done + const setVoteShow = useCallback((yes) => { if (!me) return @@ -125,7 +128,6 @@ export default function UpVote ({ item, className }) { } }, [me, tipShow, setWalkthrough]) - const [act] = useAct() const zap = useZap() const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt, @@ -155,11 +157,19 @@ export default function UpVote ({ item, className }) { } setTipShow(false) + + if (pending) { + controller.abort() + return + } + const c = new ZapUndoController() + setController(c) + showModal(onClose => - , { onClose: handleModalClosed }) + , { onClose: handleModalClosed }) } - const handleShortPress = () => { + const handleShortPress = async () => { if (me) { if (!item) return @@ -174,9 +184,16 @@ export default function UpVote ({ item, className }) { setTipShow(true) } - zap({ item, me }) + if (pending) { + controller.abort() + return + } + const c = new ZapUndoController() + setController(c) + + await zap({ item, me, abortSignal: c.signal }) } else { - showModal(onClose => , { onClose: handleModalClosed }) + showModal(onClose => , { onClose: handleModalClosed }) } } @@ -202,7 +219,8 @@ export default function UpVote ({ item, className }) { `${styles.upvote} ${className || ''} ${disabled ? styles.noSelfTips : ''} - ${meSats ? styles.voted : ''}` + ${meSats ? styles.voted : ''} + ${pending ? styles.pending : ''}` } style={meSats || hover ? { diff --git a/components/upvote.module.css b/components/upvote.module.css index cc38402e..4a2b85ae 100644 --- a/components/upvote.module.css +++ b/components/upvote.module.css @@ -34,4 +34,18 @@ position: absolute; left: 4px; width: 17px; -} \ No newline at end of file +} + +.pending { + animation-name: pulse; + animation-iteration-count: infinite; + animation-timing-function: linear; + animation-duration: 0.25s; + animation-direction: alternate; +} + +@keyframes pulse { + 0% { + fill: #a5a5a5; + } +} diff --git a/components/webln/index.js b/components/webln/index.js index 6e90b5e0..4eed911c 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -1,8 +1,6 @@ -import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { LNbitsProvider, useLNbits } from './lnbits' import { NWCProvider, useNWC } from './nwc' -import { useToast, withToastFlow } from '@/components/toast' -import { gql, useMutation } from '@apollo/client' import { LNCProvider, useLNC } from './lnc' const WebLNContext = createContext({}) @@ -86,31 +84,6 @@ function RawWebLNProvider ({ children }) { // TODO: implement fallbacks via provider priority const provider = enabledProviders[0] - const toaster = useToast() - const [cancelInvoice] = useMutation(gql` - mutation cancelInvoice($hash: String!, $hmac: String!) { - cancelInvoice(hash: $hash, hmac: $hmac) { - id - } - } - `) - - const sendPaymentWithToast = withToastFlow(toaster)( - ({ bolt11, hash, hmac, expiresAt, flowId }) => { - const expiresIn = (+new Date(expiresAt)) - (+new Date()) - return { - flowId: flowId || hash, - type: 'payment', - onPending: async () => { - await provider.sendPayment(bolt11) - }, - // hash and hmac are only passed for JIT invoices - onCancel: () => hash && hmac ? cancelInvoice({ variables: { hash, hmac } }) : undefined, - timeout: expiresIn - } - } - ) - const setProvider = useCallback((defaultProvider) => { // move provider to the start to set it as default setEnabledProviders(providers => { @@ -129,8 +102,17 @@ function RawWebLNProvider ({ children }) { await lnc.clearConfig() }, []) + const value = useMemo(() => ({ + provider: isEnabled(provider) + ? { name: provider.name, sendPayment: provider.sendPayment } + : null, + enabledProviders, + setProvider, + clearConfig + }), [provider, enabledProviders, setProvider]) + return ( - + {children} ) diff --git a/components/webln/lnc.js b/components/webln/lnc.js index ceacfb03..6d21415a 100644 --- a/components/webln/lnc.js +++ b/components/webln/lnc.js @@ -9,6 +9,7 @@ import CancelButton from '../cancel-button' import { Mutex } from 'async-mutex' import { Wallet } from '@/lib/constants' import { useMe } from '../me' +import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment' const LNCContext = createContext() const mutex = new Mutex() @@ -109,7 +110,14 @@ export function LNCProvider ({ children }) { logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) return { preimage } } catch (err) { - logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.()) + const msg = err.message || err.toString?.() + logger.error('payment failed:', `payment_hash=${hash}`, msg) + if (msg.includes('invoice expired')) { + throw new InvoiceExpiredError(hash) + } + if (msg.includes('canceled')) { + throw new InvoiceCanceledError(hash) + } throw err } finally { try { diff --git a/components/webln/nwc.js b/components/webln/nwc.js index b55a8a9e..5ec3f3f0 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -6,8 +6,9 @@ import { parseNwcUrl } from '@/lib/url' import { useWalletLogger } from '../logger' import { Status, migrateLocalStorage } from '.' import { bolt11Tags } from '@/lib/bolt11' -import { Wallet } from '@/lib/constants' +import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants' import { useMe } from '../me' +import { InvoiceExpiredError } from '../payment' const NWCContext = createContext() @@ -205,11 +206,11 @@ export function NWCProvider ({ children }) { (async function () { // timeout since NWC is async (user needs to confirm payment in wallet) // timeout is same as invoice expiry - const timeout = 180_000 + const timeout = JIT_INVOICE_TIMEOUT_MS const timer = setTimeout(() => { - const msg = 'timeout waiting for info event' + const msg = 'timeout waiting for payment' logger.error(msg) - reject(new Error(msg)) + reject(new InvoiceExpiredError(hash)) sub?.close() }, timeout) diff --git a/lib/apollo.js b/lib/apollo.js index efcc57f8..d950ed68 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -255,3 +255,17 @@ function getClient (uri) { } }) } + +export function optimisticUpdate ({ mutation, variables, optimisticResponse, update }) { + const { cache, queryManager } = getApolloClient() + + const mutationId = String(queryManager.mutationIdCounter++) + queryManager.markMutationOptimistic(optimisticResponse, { + mutationId, + document: mutation, + variables, + update + }) + + return () => cache.removeOptimistic(mutationId) +} diff --git a/lib/constants.js b/lib/constants.js index 5ac6652a..fe1977bd 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -126,6 +126,7 @@ export const ITEM_ALLOW_EDITS = [ ] export const INVOICE_RETENTION_DAYS = 7 +export const JIT_INVOICE_TIMEOUT_MS = 180_000 export const FAST_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_FAST_POLL_INTERVAL) export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_INTERVAL) @@ -148,3 +149,5 @@ export const getWalletBy = (key, value) => { } throw new Error(`wallet not found: ${key}=${value}`) } + +export const ZAP_UNDO_DELAY_MS = 5_000 diff --git a/pages/_app.js b/pages/_app.js index 94d4eb90..7bcb3d03 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -21,6 +21,7 @@ import { ChainFeeProvider } from '@/components/chain-fee.js' import { WebLNProvider } from '@/components/webln' import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' +import { ClientNotificationProvider } from '@/components/client-notifications' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -104,28 +105,30 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - - {!router?.query?.disablePrompt && } - - - - - - - - - - + + + + + + + + + + + + + {!router?.query?.disablePrompt && } + + + + + + + + + + + diff --git a/pages/invoices/[id].js b/pages/invoices/[id].js index 1838bad7..ce56889e 100644 --- a/pages/invoices/[id].js +++ b/pages/invoices/[id].js @@ -1,5 +1,5 @@ import { useQuery } from '@apollo/client' -import { Invoice } from '@/components/invoice' +import Invoice from '@/components/invoice' import { QrSkeleton } from '@/components/qr' import { CenterLayout } from '@/components/layout' import { useRouter } from 'next/router' @@ -10,6 +10,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo' // force SSR to include CSP nonces export const getServerSideProps = getGetServerSideProps({ query: null }) +// TODO: we can probably replace this component with export default function FullInvoice () { const router = useRouter() const { data, error } = useQuery(INVOICE, SSR diff --git a/pages/rewards/index.js b/pages/rewards/index.js index 92a2b965..36abcb2c 100644 --- a/pages/rewards/index.js +++ b/pages/rewards/index.js @@ -174,7 +174,7 @@ export function DonateButton () { amount: 10000 }} schema={amountSchema} - invoiceable + prepaid onSubmit={async ({ amount, hash, hmac }) => { const { error } = await donateToRewards({ variables: { diff --git a/pages/settings/index.js b/pages/settings/index.js index d701cc2a..362a7a39 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -26,7 +26,7 @@ import { NostrAuth } from '@/components/nostr-auth' import { useToast } from '@/components/toast' import { useServiceWorkerLogger } from '@/components/logger' import { useMe } from '@/components/me' -import { INVOICE_RETENTION_DAYS } from '@/lib/constants' +import { INVOICE_RETENTION_DAYS, ZAP_UNDO_DELAY_MS } from '@/lib/constants' import { OverlayTrigger, Tooltip } from 'react-bootstrap' import DeleteIcon from '@/svgs/delete-bin-line.svg' import { useField } from 'formik' @@ -1007,7 +1007,7 @@ const ZapUndosField = () => {
  • An undo button is shown after every zap that exceeds or is equal to the threshold
  • -
  • The button is shown for 5 seconds
  • +
  • The button is shown for {ZAP_UNDO_DELAY_MS / 1000} seconds
  • The button is only shown for zaps from the custodial wallet
  • Use a budget or manual approval with attached wallets