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
This commit is contained in:
parent
b81c5bcc78
commit
94cce9155d
|
@ -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(
|
||||
|
|
|
@ -107,7 +107,8 @@ export function BountyForm ({
|
|||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
}}
|
||||
schema={schema}
|
||||
invoiceable={{ requireSession: true }}
|
||||
requireSession
|
||||
prepaid
|
||||
onSubmit={
|
||||
handleSubmit ||
|
||||
onSubmit
|
||||
|
|
|
@ -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 (
|
||||
<ClientNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</ClientNotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<ClientNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</ClientNotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className='ms-2'>
|
||||
<small className='fw-bold text-danger'>
|
||||
{n.reason ? `${message}: ${n.reason}` : message}
|
||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||
</small>
|
||||
{!n.item
|
||||
? null
|
||||
: n.item.title
|
||||
? <Item item={n.item} />
|
||||
: (
|
||||
<div className='pb-2'>
|
||||
<RootProvider root={n.item.root}>
|
||||
<Comment item={n.item} noReply includeParent noComments clickToContext />
|
||||
</RootProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClientZap ({ n }) {
|
||||
const message = `failed to zap ${n.sats || n.amount} sats`
|
||||
return <ClientNotification n={n} message={message} />
|
||||
}
|
||||
|
||||
export function ClientReply ({ n }) {
|
||||
const message = 'failed to submit reply'
|
||||
return <ClientNotification n={n} message={message} />
|
||||
}
|
||||
|
||||
export function ClientBounty ({ n }) {
|
||||
const message = 'failed to pay bounty'
|
||||
return <ClientNotification n={n} message={message} />
|
||||
}
|
||||
|
||||
export function ClientPollVote ({ n }) {
|
||||
const message = 'failed to submit poll vote'
|
||||
return <ClientNotification n={n} message={message} />
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
|
@ -145,7 +145,7 @@ export default function Comment ({
|
|||
{item.outlawed && !me?.privates?.wildWestMode
|
||||
? <Skull className={styles.dontLike} width={24} height={24} />
|
||||
: item.meDontLikeSats > item.meSats
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
|
||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||
<div className='d-flex align-items-center'>
|
||||
|
|
|
@ -96,7 +96,7 @@ export function DiscussionForm ({
|
|||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
}}
|
||||
schema={schema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={handleSubmit || onSubmit}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
>
|
||||
|
|
|
@ -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 (
|
||||
<DownZapper id={id} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
|
||||
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
|
||||
)
|
||||
}
|
||||
|
||||
function DownZapper ({ id, As, children }) {
|
||||
function DownZapper ({ item, As, children }) {
|
||||
const toaster = useToast()
|
||||
const showModal = useShowModal()
|
||||
const me = useMe()
|
||||
|
||||
return (
|
||||
<As
|
||||
|
@ -32,12 +31,7 @@ function DownZapper ({ id, As, children }) {
|
|||
try {
|
||||
showModal(onClose =>
|
||||
<ItemAct
|
||||
onClose={(amount) => {
|
||||
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
|
||||
>
|
||||
<AccordianItem
|
||||
header='what is a downzap?' body={
|
||||
|
@ -59,11 +53,11 @@ function DownZapper ({ id, As, children }) {
|
|||
)
|
||||
}
|
||||
|
||||
export default function DontLikeThisDropdownItem ({ id }) {
|
||||
export default function DontLikeThisDropdownItem ({ item }) {
|
||||
return (
|
||||
<DownZapper
|
||||
As={Dropdown.Item}
|
||||
id={id}
|
||||
item={item}
|
||||
>
|
||||
<span className='text-danger'>downzap</span>
|
||||
</DownZapper>
|
||||
|
|
|
@ -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 (
|
||||
<FeeButtonContext.Provider value={value}>
|
||||
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix])
|
||||
|
||||
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)
|
||||
}
|
||||
}, [me, onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix, payment, signal])
|
||||
|
||||
return (
|
||||
<Formik
|
||||
|
|
|
@ -1,23 +1,35 @@
|
|||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { gql } from 'graphql-tag'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
import AccordianItem from './accordian-item'
|
||||
import Qr, { QrSkeleton } from './qr'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import InvoiceStatus from './invoice-status'
|
||||
import { useMe } from './me'
|
||||
import { useShowModal } from './modal'
|
||||
import Qr from './qr'
|
||||
import Countdown from './countdown'
|
||||
import PayerData from './payer-data'
|
||||
import Bolt11Info from './bolt11-info'
|
||||
import { useWebLN } from './webln'
|
||||
import { FAST_POLL_INTERVAL } from '@/lib/constants'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||
import { WebLnNotEnabledError } from './payment'
|
||||
|
||||
export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }) {
|
||||
export default function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn, webLnError, poll }) {
|
||||
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
|
||||
|
||||
const { data, error } = useQuery(INVOICE, SSR
|
||||
? {}
|
||||
: {
|
||||
pollInterval: FAST_POLL_INTERVAL,
|
||||
variables: { id: invoice.id },
|
||||
nextFetchPolicy: 'cache-and-network',
|
||||
skip: !poll
|
||||
})
|
||||
|
||||
if (data) {
|
||||
invoice = data.invoice
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>{error.toString()}</div>
|
||||
}
|
||||
|
||||
// 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) &&
|
||||
<div className='text-center text-danger mb-3'>
|
||||
Payment from attached wallet failed:
|
||||
<div>{webLnError.toString()}</div>
|
||||
</div>}
|
||||
<Qr
|
||||
webLn={webLn} value={invoice.bolt11}
|
||||
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
||||
|
@ -105,289 +122,3 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 <div>error</div>
|
||||
}
|
||||
if (!data || loading) {
|
||||
return <QrSkeleton description status='loading' />
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Invoice invoice={data.invoice} modal onPayment={onPayment} successVerb='received' webLn={false} />
|
||||
{retry
|
||||
? (
|
||||
<>
|
||||
<div className='my-3'>
|
||||
<InvoiceStatus variant='failed' status={errorStatus} />
|
||||
</div>
|
||||
<div className='d-flex flex-row mt-3 justify-content-center'>
|
||||
<Button
|
||||
className='mx-1' variant='info' onClick={async () => {
|
||||
try {
|
||||
await onRetry()
|
||||
} catch (err) {
|
||||
console.error('retry error:', err)
|
||||
setRetryError(retryError => retryError + 1)
|
||||
}
|
||||
}}
|
||||
>Retry
|
||||
</Button>
|
||||
<Button
|
||||
className='mx-1'
|
||||
variant='danger'
|
||||
onClick={onCancel}
|
||||
>Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
: 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 (
|
||||
<JITInvoice
|
||||
invoice={inv}
|
||||
onCancel={async () => {
|
||||
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 (
|
||||
<JITInvoice
|
||||
invoice={invoice}
|
||||
onPayment={() => 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))
|
||||
}
|
||||
|
|
|
@ -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,159 +44,13 @@ 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
|
||||
}
|
||||
|
||||
export default function ItemAct ({ onClose, itemId, down, children }) {
|
||||
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])
|
||||
|
||||
const [act, actUpdate] = useAct()
|
||||
|
||||
const onSubmit = useCallback(async ({ amount, hash, hmac }, { update, keepOpen }) => {
|
||||
if (!me) {
|
||||
const storageKey = `TIP-item:${itemId}`
|
||||
const setItemMeAnonSats = ({ id, amount }) => {
|
||||
const storageKey = `TIP-item:${id}`
|
||||
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
|
||||
window.localStorage.setItem(storageKey, existingAmount + amount)
|
||||
}
|
||||
await act({
|
||||
variables: {
|
||||
id: itemId,
|
||||
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()
|
||||
addCustomTip(Number(amount))
|
||||
if (!keepOpen) onClose(Number(amount))
|
||||
}, [me, act, down, itemId, 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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
amount: me?.privates?.tipDefault || defaultTips[0],
|
||||
default: false
|
||||
}}
|
||||
schema={amountSchema}
|
||||
invoiceable
|
||||
onSubmit={(values, ...args) => {
|
||||
if (zapUndosThresholdReached(me, values.amount)) {
|
||||
return onSubmitWithUndos(values, ...args)
|
||||
}
|
||||
return onSubmit(values, ...args)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
type='number'
|
||||
innerRef={inputRef}
|
||||
overrideValue={oValue}
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<div>
|
||||
<Tips setOValue={setOValue} />
|
||||
</div>
|
||||
{children}
|
||||
<div className='d-flex mt-3'>
|
||||
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAct ({ onUpdate } = {}) {
|
||||
const me = useMe()
|
||||
|
||||
const update = useCallback((cache, args) => {
|
||||
export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
|
||||
const { data: { act: { id, sats, path, act } } } = args
|
||||
|
||||
cache.modify({
|
||||
|
@ -206,15 +63,13 @@ export function useAct ({ onUpdate } = {}) {
|
|||
|
||||
return existingSats
|
||||
},
|
||||
meSats: me
|
||||
? (existingSats = 0) => {
|
||||
meSats: (existingSats = 0) => {
|
||||
if (act === 'TIP') {
|
||||
return existingSats + sats
|
||||
}
|
||||
|
||||
return existingSats
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
meDontLikeSats: me
|
||||
? (existingSats = 0) => {
|
||||
if (act === 'DONT_LIKE_THIS') {
|
||||
|
@ -240,13 +95,86 @@ export function useAct ({ onUpdate } = {}) {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUpdate && onUpdate(cache, args)
|
||||
}
|
||||
}, [!!me, onUpdate])
|
||||
|
||||
const [act] = useMutation(
|
||||
gql`
|
||||
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()
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [onClose, item.id])
|
||||
|
||||
const act = useAct()
|
||||
|
||||
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
|
||||
await act({
|
||||
variables: {
|
||||
id: item.id,
|
||||
sats: Number(amount),
|
||||
act: down ? 'DONT_LIKE_THIS' : 'TIP',
|
||||
hash,
|
||||
hmac
|
||||
}
|
||||
})
|
||||
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||
addCustomTip(Number(amount))
|
||||
}, [me, act, down, item.id, strike])
|
||||
|
||||
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 (
|
||||
<ClientNotifyProvider additionalProps={{ itemId: item.id }}>
|
||||
<Form
|
||||
initial={{
|
||||
amount: me?.privates?.tipDefault || defaultTips[0],
|
||||
default: false
|
||||
}}
|
||||
schema={amountSchema}
|
||||
prepaid
|
||||
optimisticUpdate={optimisticUpdate}
|
||||
onSubmit={onSubmit}
|
||||
clientNotification={ClientNotification.Zap}
|
||||
signal={abortSignal}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
type='number'
|
||||
innerRef={inputRef}
|
||||
overrideValue={oValue}
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<div>
|
||||
<Tips setOValue={setOValue} />
|
||||
</div>
|
||||
{children}
|
||||
<div className='d-flex mt-3'>
|
||||
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</ClientNotifyProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -254,9 +182,11 @@ export function useAct ({ onUpdate } = {}) {
|
|||
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) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -176,7 +176,7 @@ export default function ItemInfo ({
|
|||
!item.mine && !item.deletedAt &&
|
||||
(item.meDontLikeSats > meTotalSats
|
||||
? <DropdownItemUpVote item={item} />
|
||||
: <DontLikeThisDropdownItem id={item.id} />)}
|
||||
: <DontLikeThisDropdownItem item={item} />)}
|
||||
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
|
||||
<>
|
||||
<hr className='dropdown-divider' />
|
||||
|
|
|
@ -63,7 +63,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
|||
{item.position && (pinnable || !item.subName)
|
||||
? <Pin width={24} height={24} className={styles.pin} />
|
||||
: item.meDontLikeSats > item.meSats
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
|
||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||
: Number(item.user?.id) === AD_USER_ID
|
||||
? <AdIcon width={24} height={24} className={styles.ad} />
|
||||
: <UpVote item={item} className={styles.upvote} />}
|
||||
|
|
|
@ -105,7 +105,8 @@ export default function JobForm ({ item, sub }) {
|
|||
}}
|
||||
schema={jobSchema}
|
||||
storageKeyPrefix={storageKeyPrefix}
|
||||
invoiceable={{ requireSession: true }}
|
||||
requireSession
|
||||
prepaid
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div className='form-group'>
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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 (
|
||||
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
|
||||
<NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}>
|
||||
{
|
||||
(type === 'Earn' && <EarnNotification n={n} />) ||
|
||||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
|
||||
|
@ -53,7 +60,11 @@ function Notification ({ n, fresh }) {
|
|||
(type === 'FollowActivity' && <FollowActivity n={n} />) ||
|
||||
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
|
||||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
|
||||
(type === 'Reminder' && <Reminder n={n} />)
|
||||
(type === 'Reminder' && <Reminder n={n} />) ||
|
||||
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && <ClientZap n={itemN} />) ||
|
||||
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(type) && <ClientReply n={itemN} />) ||
|
||||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(type) && <ClientBounty n={itemN} />) ||
|
||||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(type) && <ClientPollVote n={itemN} />)
|
||||
}
|
||||
</NotificationLayout>
|
||||
)
|
||||
|
@ -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 <CommentsFlatSkeleton />
|
||||
|
||||
const sorted = [...clientNotifications, ...notifications]
|
||||
.sort((a, b) => new Date(b.sortTime).getTime() - new Date(a.sortTime).getTime())
|
||||
|
||||
return (
|
||||
<>
|
||||
{notifications.map(n =>
|
||||
{sorted.map(n =>
|
||||
<Notification
|
||||
n={n} key={nid(n)}
|
||||
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt ?? lastChecked)}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 =>
|
||||
<Invoice
|
||||
invoice={inv}
|
||||
modal
|
||||
successVerb='received'
|
||||
webLn={false}
|
||||
webLnError={webLnError}
|
||||
onPayment={() => { 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 }
|
||||
}
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -8,16 +8,22 @@ 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`
|
||||
const POLL_VOTE_MUTATION = gql`
|
||||
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
|
||||
pollVote(id: $id, hash: $hash, hmac: $hmac)
|
||||
}`, {
|
||||
update (cache, { data: { pollVote } }) {
|
||||
}`
|
||||
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: {
|
||||
|
@ -41,35 +47,46 @@ export default function Poll ({ item }) {
|
|||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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 (
|
||||
<ActionTooltip placement='left' notForm overlayText='1 sat'>
|
||||
<Button
|
||||
variant='outline-info' className={styles.pollButton}
|
||||
onClick={me
|
||||
? async () => {
|
||||
const variables = { id: v.id }
|
||||
const notifyProps = { itemId: item.id }
|
||||
const optimisticResponse = { pollVote: v.id }
|
||||
let revert, cancel, nid
|
||||
try {
|
||||
await pollVote({
|
||||
variables,
|
||||
optimisticResponse: {
|
||||
pollVote: v.id
|
||||
revert = optimisticUpdate({ mutation: POLL_VOTE_MUTATION, variables, optimisticResponse, update })
|
||||
|
||||
if (me) {
|
||||
nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
|
||||
}
|
||||
})
|
||||
|
||||
let hash, hmac;
|
||||
[{ hash, hmac }, cancel] = await payment.request(item.pollCost || POLL_COST)
|
||||
await pollVote({ variables: { hash, hmac, ...variables } })
|
||||
} catch (error) {
|
||||
if (payOrLoginError(error)) {
|
||||
showInvoiceModal({ amount: item.pollCost || POLL_COST }, { variables })
|
||||
revert?.()
|
||||
|
||||
if (error instanceof InvoiceCanceledError) {
|
||||
return
|
||||
}
|
||||
throw new Error({ message: error.toString() })
|
||||
|
||||
const reason = error?.message || error?.toString?.()
|
||||
if (me) {
|
||||
notify(ClientNotification.PollVote.ERROR, { ...notifyProps, reason })
|
||||
} else {
|
||||
toaster.danger('poll vote failed: ' + reason)
|
||||
}
|
||||
|
||||
cancel?.()
|
||||
} finally {
|
||||
if (nid) unnotify(nid)
|
||||
}
|
||||
}
|
||||
: signIn}
|
||||
|
|
|
@ -14,7 +14,7 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st
|
|||
async function effect () {
|
||||
if (webLn && provider) {
|
||||
try {
|
||||
await provider.sendPayment({ bolt11: value })
|
||||
await provider.sendPayment(value)
|
||||
} catch (e) {
|
||||
console.log(e?.message)
|
||||
}
|
||||
|
|
|
@ -166,7 +166,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
|
|||
text: ''
|
||||
}}
|
||||
schema={commentSchema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={onSubmit}
|
||||
storageKeyPrefix={`reply-${parentId}`}
|
||||
>
|
||||
|
|
|
@ -112,7 +112,7 @@ export default function TerritoryForm ({ sub }) {
|
|||
nsfw: sub?.nsfw || false
|
||||
}}
|
||||
schema={schema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={onSubmit}
|
||||
className='mb-5'
|
||||
storageKeyPrefix={sub ? undefined : 'territory'}
|
||||
|
|
|
@ -56,7 +56,7 @@ export default function TerritoryPaymentDue ({ sub }) {
|
|||
|
||||
<FeeButtonProvider baseLineItems={{ territory: TERRITORY_BILLING_OPTIONS('one')[sub.billingType.toLowerCase()] }}>
|
||||
<Form
|
||||
invoiceable
|
||||
prepaid
|
||||
initial={{
|
||||
name: sub.name
|
||||
}}
|
||||
|
|
|
@ -10,21 +10,6 @@ const ToastContext = createContext(() => {})
|
|||
|
||||
export const TOAST_DEFAULT_DELAY_MS = 5000
|
||||
|
||||
const ensureFlow = (toasts, newToast) => {
|
||||
const { flowId } = newToast
|
||||
if (flowId) {
|
||||
// replace previous toast with same flow id
|
||||
const idx = toasts.findIndex(toast => toast.flowId === flowId)
|
||||
if (idx === -1) return [...toasts, newToast]
|
||||
return [
|
||||
...toasts.slice(0, idx),
|
||||
newToast,
|
||||
...toasts.slice(idx + 1)
|
||||
]
|
||||
}
|
||||
return [...toasts, newToast]
|
||||
}
|
||||
|
||||
const mapHidden = ({ id, tag }) => toast => {
|
||||
// mark every previous toast with same tag as hidden
|
||||
if (toast.tag === tag && toast.id !== id) return { ...toast, hidden: true }
|
||||
|
@ -36,24 +21,15 @@ export const ToastProvider = ({ children }) => {
|
|||
const [toasts, setToasts] = useState([])
|
||||
const toastId = useRef(0)
|
||||
|
||||
const removeToast = useCallback(({ id, onCancel, tag }) => {
|
||||
const removeToast = useCallback(({ id, tag }) => {
|
||||
setToasts(toasts => toasts.filter(toast => {
|
||||
if (toast.id === id) {
|
||||
// remove the toast with the passed id with no exceptions
|
||||
return false
|
||||
}
|
||||
const sameTag = tag && tag === toast.tag
|
||||
if (!sameTag) {
|
||||
// don't touch toasts with different tags
|
||||
return true
|
||||
}
|
||||
const toRemoveHasCancel = !!toast.onCancel || !!toast.onUndo
|
||||
if (toRemoveHasCancel) {
|
||||
// don't remove this toast so the user can decide to cancel this toast now
|
||||
return true
|
||||
}
|
||||
// remove toasts with same tag if they are not cancelable
|
||||
return false
|
||||
// remove toasts with same tag
|
||||
return !sameTag
|
||||
}))
|
||||
}, [setToasts])
|
||||
|
||||
|
@ -63,14 +39,10 @@ export const ToastProvider = ({ children }) => {
|
|||
createdAt: +new Date(),
|
||||
id: toastId.current++
|
||||
}
|
||||
setToasts(toasts => ensureFlow(toasts, toast).map(mapHidden(toast)))
|
||||
setToasts(toasts => [...toasts, toast].map(mapHidden(toast)))
|
||||
return () => removeToast(toast)
|
||||
}, [setToasts, removeToast])
|
||||
|
||||
const endFlow = useCallback((flowId) => {
|
||||
setToasts(toasts => toasts.filter(toast => toast.flowId !== flowId))
|
||||
}, [setToasts])
|
||||
|
||||
const toaster = useMemo(() => ({
|
||||
success: (body, options) => {
|
||||
const toast = {
|
||||
|
@ -103,14 +75,13 @@ export const ToastProvider = ({ children }) => {
|
|||
...options
|
||||
}
|
||||
return dispatchToast(toast)
|
||||
},
|
||||
endFlow
|
||||
}), [dispatchToast, removeToast, endFlow])
|
||||
}
|
||||
}), [dispatchToast, removeToast])
|
||||
|
||||
// Only clear toasts with no cancel function on page navigation
|
||||
// since navigation should not interfere with being able to cancel an action.
|
||||
useEffect(() => {
|
||||
const handleRouteChangeStart = () => setToasts(toasts => toasts.length > 0 ? toasts.filter(({ onCancel, onUndo, persistOnNavigate }) => onCancel || onUndo || persistOnNavigate) : toasts)
|
||||
const handleRouteChangeStart = () => setToasts(toasts => toasts.length > 0 ? toasts.filter(({ persistOnNavigate }) => persistOnNavigate) : toasts)
|
||||
router.events.on('routeChangeStart', handleRouteChangeStart)
|
||||
|
||||
return () => {
|
||||
|
@ -151,16 +122,9 @@ export const ToastProvider = ({ children }) => {
|
|||
{visibleToasts.map(toast => {
|
||||
const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
|
||||
const onClose = () => {
|
||||
toast.onUndo?.()
|
||||
toast.onCancel?.()
|
||||
toast.onClose?.()
|
||||
removeToast(toast)
|
||||
}
|
||||
const buttonElement = toast.onUndo
|
||||
? <div className={`${styles.toastUndo} ${textStyle}`}>undo</div>
|
||||
: toast.onCancel
|
||||
? <div className={`${styles.toastCancel} ${textStyle}`}>cancel</div>
|
||||
: <div className={`${styles.toastClose} ${textStyle}`}>X</div>
|
||||
// a toast is unhidden if it was hidden before since it now gets rendered
|
||||
const unhidden = toast.hidden
|
||||
// we only need to start the animation at a different timing when it was hidden by another toast before.
|
||||
|
@ -181,7 +145,7 @@ export const ToastProvider = ({ children }) => {
|
|||
className='p-0 ps-2'
|
||||
aria-label='close'
|
||||
onClick={onClose}
|
||||
>{buttonElement}
|
||||
><div className={`${styles.toastClose} ${textStyle}`}>X</div>
|
||||
</Button>
|
||||
</div>
|
||||
</ToastBody>
|
||||
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 }) {
|
|||
<Dropdown.Item
|
||||
onClick={async () => {
|
||||
showModal(onClose =>
|
||||
<ItemAct onClose={onClose} itemId={item.id} />)
|
||||
<ItemAct onClose={onClose} item={item} />)
|
||||
}}
|
||||
>
|
||||
<span className='text-success'>zap</span>
|
||||
|
@ -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 =>
|
||||
<ItemAct onClose={onClose} itemId={item.id} />, { onClose: handleModalClosed })
|
||||
<ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { 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 => <ItemAct onClose={onClose} itemId={item.id} act={act} />, { onClose: handleModalClosed })
|
||||
showModal(onClose => <ItemAct onClose={onClose} item={item} />, { 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
|
||||
? {
|
||||
|
|
|
@ -35,3 +35,17 @@
|
|||
left: 4px;
|
||||
width: 17px;
|
||||
}
|
||||
|
||||
.pending {
|
||||
animation-name: pulse;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
animation-duration: 0.25s;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
fill: #a5a5a5;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<WebLNContext.Provider value={{ provider: isEnabled(provider) ? { name: provider.name, sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider, clearConfig }}>
|
||||
<WebLNContext.Provider value={value}>
|
||||
{children}
|
||||
</WebLNContext.Provider>
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,6 +105,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
|||
<ApolloProvider client={client}>
|
||||
<MeProvider me={me}>
|
||||
<HasNewNotesProvider>
|
||||
<ClientNotificationProvider>
|
||||
<LoggerProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
|
@ -126,6 +128,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
|||
</PriceProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</LoggerProvider>
|
||||
</ClientNotificationProvider>
|
||||
</HasNewNotesProvider>
|
||||
</MeProvider>
|
||||
</ApolloProvider>
|
||||
|
|
|
@ -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 <Invoice poll>
|
||||
export default function FullInvoice () {
|
||||
const router = useRouter()
|
||||
const { data, error } = useQuery(INVOICE, SSR
|
||||
|
|
|
@ -174,7 +174,7 @@ export function DonateButton () {
|
|||
amount: 10000
|
||||
}}
|
||||
schema={amountSchema}
|
||||
invoiceable
|
||||
prepaid
|
||||
onSubmit={async ({ amount, hash, hmac }) => {
|
||||
const { error } = await donateToRewards({
|
||||
variables: {
|
||||
|
|
|
@ -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 = () => {
|
|||
<Info>
|
||||
<ul className='fw-bold'>
|
||||
<li>An undo button is shown after every zap that exceeds or is equal to the threshold</li>
|
||||
<li>The button is shown for 5 seconds</li>
|
||||
<li>The button is shown for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
|
||||
<li>The button is only shown for zaps from the custodial wallet</li>
|
||||
<li>Use a budget or manual approval with attached wallets</li>
|
||||
</ul>
|
||||
|
|
Loading…
Reference in New Issue