{
+ return (
+
+ )
+}
+
export default function MoreFooter ({ cursor, count, fetchMore, Skeleton, invisible, noMoreText = 'GENESIS' }) {
const [loading, setLoading] = useState(false)
@@ -9,31 +29,14 @@ export default function MoreFooter ({ cursor, count, fetchMore, Skeleton, invisi
return
}
- let Footer
- if (cursor) {
- Footer = () => (
-
- )
- } else {
+ let Footer = FooterFetchMore
+ if (!cursor) {
Footer = () => (
{count === 0 ? 'EMPTY' : noMoreText}
)
}
- return
+ return
}
export function NavigateFooter ({ cursor, count, fetchMore, href, text, invisible, noMoreText = 'NO MORE' }) {
diff --git a/components/notifications.js b/components/notifications.js
index 7c12f489ad..a09fc03162 100644
--- a/components/notifications.js
+++ b/components/notifications.js
@@ -1,4 +1,4 @@
-import { useState, useEffect, useMemo } from 'react'
+import { useState, useEffect, useMemo, useCallback } from 'react'
import { gql, useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment'
import Item from './item'
@@ -26,25 +26,20 @@ import { useData } from './use-data'
import { nostrZapDetails } from '@/lib/nostr'
import Text from './text'
import NostrIcon from '@/svgs/nostr.svg'
-import { numWithUnits } from '@/lib/format'
+import { msatsToSats, numWithUnits } from '@/lib/format'
import BountyIcon from '@/svgs/bounty-bag.svg'
import { LongCountdown } from './countdown'
import { nextBillingWithGrace } from '@/lib/territory'
import { commentSubTreeRootId } from '@/lib/item'
import LinkToContext from './link-to-context'
import { Badge, Button } from 'react-bootstrap'
-import { useAct } from './item-act'
-import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
-import { usePollVote } from './poll'
-import { paidActionCacheMods } from './use-paid-mutation'
-import { useRetryCreateItem } from './use-item-submit'
-import { payBountyCacheMods } from './pay-bounty'
import { useToast } from './toast'
import classNames from 'classnames'
import HolsterIcon from '@/svgs/holster.svg'
import SaddleIcon from '@/svgs/saddle.svg'
import CCInfo from './info/cc'
import { useMe } from './me'
+import { useRetryPayIn, useRetryBountyPayIn, useRetryItemActPayIn } from './payIn/hooks/use-retry-pay-in'
function Notification ({ n, fresh }) {
const type = n.__typename
@@ -72,7 +67,7 @@ function Notification ({ n, fresh }) {
(type === 'TerritoryPost' &&
) ||
(type === 'TerritoryTransfer' &&
) ||
(type === 'Reminder' &&
) ||
- (type === 'Invoicification' &&
) ||
+ (type === 'PayInFailed' &&
) ||
(type === 'ReferralReward' &&
)
}
@@ -84,7 +79,7 @@ function NotificationLayout ({ children, type, nid, href, as, fresh }) {
if (!href) return
{children}
return (
{
e.preventDefault()
nid && await router.replace({
@@ -97,6 +92,7 @@ function NotificationLayout ({ children, type, nid, href, as, fresh }) {
router.push(href, as)
}}
href={href}
+ pad
>
{children}
@@ -163,7 +159,7 @@ const defaultOnClick = n => {
if (type === 'SubStatus') return { href: `/~${n.sub.name}` }
if (type === 'Invitification') return { href: '/invites' }
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
- if (type === 'Invoicification') return itemLink(n.invoice.item)
+ if (type === 'PayInFailed') return itemLink(n.item)
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
if (type === 'Referral') return { href: '/referrals/month' }
if (type === 'ReferralReward') return { href: '/referrals/month' }
@@ -405,114 +401,85 @@ function InvoicePaid ({ n }) {
)
}
-function useActRetry ({ invoice }) {
- const bountyCacheMods =
- invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine
- ? payBountyCacheMods
- : {}
-
- const update = (cache, { data }) => {
- const response = Object.values(data)[0]
- if (!response?.invoice) return
- cache.modify({
- id: `ItemAct:${invoice.itemAct?.id}`,
- fields: {
- // this is a bit of a hack just to update the reference to the new invoice
- invoice: () => cache.writeFragment({
- id: `Invoice:${response.invoice.id}`,
- fragment: gql`
- fragment _ on Invoice {
- bolt11
- }
- `,
- data: { bolt11: response.invoice.bolt11 }
- })
+function PayInFailed ({ n }) {
+ const [disableRetry, setDisableRetry] = useState(false)
+ const toaster = useToast()
+ const { payIn, item } = n
+ const updatePayIn = useCallback((cache, { data }) => {
+ cache.writeFragment({
+ id: `PayInFailed:${n.id}`,
+ fragment: gql`
+ fragment _ on PayInFailed {
+ payIn {
+ id
+ mcost
+ payInType
+ payInState
+ payInStateChangedAt
+ }
+ }
+ `,
+ data: {
+ payIn: data.retryPayIn
}
})
- paidActionCacheMods?.update?.(cache, { data })
- bountyCacheMods?.update?.(cache, { data })
- }
-
- return useAct({
- query: RETRY_PAID_ACTION,
- onPayError: (e, cache, { data }) => {
- paidActionCacheMods?.onPayError?.(e, cache, { data })
- bountyCacheMods?.onPayError?.(e, cache, { data })
- },
- onPaid: (cache, { data }) => {
- paidActionCacheMods?.onPaid?.(cache, { data })
- bountyCacheMods?.onPaid?.(cache, { data })
- },
- update,
- updateOnFallback: update
- })
-}
-
-function Invoicification ({ n: { invoice, sortTime } }) {
- const toaster = useToast()
- const actRetry = useActRetry({ invoice })
- const retryCreateItem = useRetryCreateItem({ id: invoice.item?.id })
- const retryPollVote = usePollVote({ query: RETRY_PAID_ACTION, itemId: invoice.item?.id })
- const [disableRetry, setDisableRetry] = useState(false)
- // XXX if we navigate to an invoice after it is retried in notifications
- // the cache will clear invoice.item and will error on window.back
- // alternatively, we could/should
- // 1. update the notification cache to include the new invoice
- // 2. make item has-many invoices
- if (!invoice.item) return null
-
- let retry
- let actionString
- let invoiceId
- let invoiceActionState
- const itemType = invoice.item.title ? 'post' : 'comment'
-
- if (invoice.actionType === 'ITEM_CREATE') {
- actionString = `${itemType} create `
- retry = retryCreateItem;
- ({ id: invoiceId, actionState: invoiceActionState } = invoice.item.invoice)
- } else if (invoice.actionType === 'POLL_VOTE') {
- actionString = 'poll vote '
- retry = retryPollVote
- invoiceId = invoice.item.poll?.meInvoiceId
- invoiceActionState = invoice.item.poll?.meInvoiceActionState
- } else {
- if (invoice.actionType === 'ZAP') {
- if (invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine) {
+ }, [n.id])
+
+ const retryPayIn = useRetryPayIn(payIn.id, { update: updatePayIn })
+ const act = payIn.payInType === 'ZAP' ? 'TIP' : payIn.payInType === 'DOWN_ZAP' ? 'DONT_LIKE_THIS' : 'BOOST'
+ const optimisticResponse = { payInType: payIn.payInType, mcost: payIn.mcost, result: { id: item.id, sats: msatsToSats(payIn.mcost), path: item.path, act, __typename: 'ItemAct' } }
+ const retryBountyPayIn = useRetryBountyPayIn(payIn.id, { update: updatePayIn, optimisticResponse })
+ const retryItemActPayIn = useRetryItemActPayIn(payIn.id, { update: updatePayIn, optimisticResponse })
+
+ const [actionString, colorClass, retry] = useMemo(() => {
+ let retry
+ let actionString = ''
+ const itemType = item.title ? 'post' : 'comment'
+ if (payIn.payInType === 'ITEM_CREATE') {
+ actionString = `${itemType} create `
+ retry = retryPayIn
+ } else if (payIn.payInType === 'POLL_VOTE') {
+ actionString = 'poll vote '
+ retry = retryPayIn
+ } else {
+ if (payIn.payInType === 'ZAP' && item.root?.bounty === msatsToSats(payIn.mcost) && item.root?.mine) {
actionString = 'bounty payment'
+ retry = retryBountyPayIn
} else {
- actionString = 'zap'
+ if (payIn.payInType === 'ZAP') {
+ actionString = 'zap'
+ } else if (payIn.payInType === 'DOWN_ZAP') {
+ actionString = 'downzap'
+ } else if (payIn.payInType === 'BOOST') {
+ actionString = 'boost'
+ }
+ retry = retryItemActPayIn
}
- } else if (invoice.actionType === 'DOWN_ZAP') {
- actionString = 'downzap'
- } else if (invoice.actionType === 'BOOST') {
- actionString = 'boost'
+ actionString = `${actionString} on ${itemType} `
}
- actionString = `${actionString} on ${itemType} `
- retry = actRetry;
- ({ id: invoiceId, actionState: invoiceActionState } = invoice.itemAct.invoice)
- }
-
- let colorClass = 'info'
- switch (invoiceActionState) {
- case 'FAILED':
- actionString += 'failed'
- colorClass = 'warning'
- break
- case 'PAID':
- actionString += 'paid'
- colorClass = 'success'
- break
- default:
- actionString += 'pending'
- }
+ let colorClass = 'info'
+ switch (payIn.payInState) {
+ case 'FAILED':
+ case 'CANCELLED':
+ actionString += 'failed'
+ colorClass = 'warning'
+ break
+ case 'PAID':
+ actionString += 'paid'
+ colorClass = 'success'
+ break
+ default:
+ actionString += 'pending'
+ }
+ return [actionString, colorClass, retry]
+ }, [payIn, item, retryPayIn, retryBountyPayIn, retryItemActPayIn])
return (
{actionString}
- {numWithUnits(invoice.satsRequested)}
-
+ {numWithUnits(msatsToSats(payIn.mcost))}
+
- {timeSince(new Date(sortTime))}
+ {timeSince(new Date(payIn.payInStateChangedAt))}
-
+
)
}
diff --git a/components/pay-bounty.js b/components/pay-bounty.js
index b4d079e559..111f2fef50 100644
--- a/components/pay-bounty.js
+++ b/components/pay-bounty.js
@@ -2,7 +2,7 @@ import React from 'react'
import styles from './pay-bounty.module.css'
import ActionTooltip from './action-tooltip'
import { useMe } from './me'
-import { numWithUnits } from '@/lib/format'
+import { numWithUnits, satsToMsats } from '@/lib/format'
import { useShowModal } from './modal'
import { useRoot } from './root'
import { ActCanceledError, useAct } from './item-act'
@@ -12,9 +12,10 @@ import { useHasSendWallet } from '@/wallets/client/hooks'
import { Form, SubmitButton } from './form'
export const payBountyCacheMods = {
- onPaid: (cache, { data }) => {
+ update: (cache, { data }) => {
const response = Object.values(data)[0]
if (!response?.result) return
+ console.log('payBounty: update', response)
const { id, path } = response.result
const root = path.split('.')[0]
cache.modify({
@@ -30,6 +31,7 @@ export const payBountyCacheMods = {
onPayError: (e, cache, { data }) => {
const response = Object.values(data)[0]
if (!response?.result) return
+ console.log('payBounty: onPayError', response)
const { id, path } = response.result
const root = path.split('.')[0]
cache.modify({
@@ -53,9 +55,10 @@ export default function PayBounty ({ children, item }) {
const hasSendWallet = useHasSendWallet()
const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet }
+ console.log('payBounty', item.path)
const act = useAct({
variables,
- optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } },
+ optimisticResponse: { payInType: 'ZAP', mcost: satsToMsats(root.bounty), result: { path: item.path, id: item.id, sats: root.bounty, act: 'TIP', __typename: 'ItemAct' } },
...payBountyCacheMods
})
diff --git a/components/payIn/bolt11-info.js b/components/payIn/bolt11-info.js
new file mode 100644
index 0000000000..96027d55f6
--- /dev/null
+++ b/components/payIn/bolt11-info.js
@@ -0,0 +1,59 @@
+import { CopyInput } from '@/components/form'
+import { bolt11Tags } from '@/lib/bolt11'
+
+export default ({ bolt11, preimage, children }) => {
+ let description, paymentHash
+ if (bolt11) {
+ ({ description, payment_hash: paymentHash } = bolt11Tags(bolt11))
+ }
+
+ return (
+
+ {bolt11 &&
+ <>
+
bolt11
+
+ >}
+ {paymentHash &&
+ <>
+
hash
+
+ >}
+ {preimage &&
+ <>
+
preimage
+
+ >}
+ {description &&
+ <>
+
description
+
+ >}
+ {children}
+
+ )
+}
diff --git a/components/payIn/context.js b/components/payIn/context.js
new file mode 100644
index 0000000000..98ea4fec71
--- /dev/null
+++ b/components/payIn/context.js
@@ -0,0 +1,37 @@
+import ItemJob from '@/components/item-job'
+import Item from '@/components/item'
+import { CommentFlat } from '@/components/comment'
+import { TerritoryDetails } from '../territory-header'
+import { truncateString } from '@/lib/format'
+
+export function PayInContext ({ payIn }) {
+ switch (payIn.payInType) {
+ case 'ITEM_CREATE':
+ case 'ITEM_UPDATE':
+ case 'ZAP':
+ case 'DOWN_ZAP':
+ case 'BOOST':
+ case 'POLL_VOTE':
+ return (
+ <>
+ {!payIn.item.title &&
}
+ {payIn.item.isJob &&
}
+ {payIn.item.title &&
}
+ >
+ )
+ case 'TERRITORY_CREATE':
+ case 'TERRITORY_UPDATE':
+ case 'TERRITORY_BILLING':
+ case 'TERRITORY_UNARCHIVE':
+ return
+ case 'INVITE_GIFT':
+ return
TODO: Invite
+ case 'PROXY_PAYMENT':
+ return
TODO: Proxy Payment
+ case 'WITHDRAWAL':
+ case 'AUTOWITHDRAWAL':
+ case 'DONATE':
+ case 'BUY_CREDITS':
+ }
+ return
N/A
+}
diff --git a/components/payIn/error.js b/components/payIn/error.js
new file mode 100644
index 0000000000..d0a4bbf249
--- /dev/null
+++ b/components/payIn/error.js
@@ -0,0 +1,21 @@
+import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors'
+
+export default function PayInError ({ error }) {
+ if (!error || error instanceof WalletConfigurationError) return null
+
+ if (!(error instanceof WalletPaymentAggregateError)) {
+ console.error('unexpected wallet error:', error)
+ return null
+ }
+
+ return (
+
+
Paying from attached wallets failed:
+ {error.errors.map((e, i) => (
+
+ {e.wallet}: {e.reason || e.message}
+
+ ))}
+
+ )
+}
diff --git a/components/payIn/hooks/use-auto-retry-pay-ins.js b/components/payIn/hooks/use-auto-retry-pay-ins.js
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/components/payIn/hooks/use-pay-in-helper.js b/components/payIn/hooks/use-pay-in-helper.js
new file mode 100644
index 0000000000..13ecaebce7
--- /dev/null
+++ b/components/payIn/hooks/use-pay-in-helper.js
@@ -0,0 +1,118 @@
+import { useApolloClient, useMutation } from '@apollo/client'
+import { useCallback, useMemo } from 'react'
+import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/client/errors'
+import { GET_PAY_IN_RESULT, CANCEL_PAY_IN_BOLT11, RETRY_PAY_IN } from '@/fragments/payIn'
+import { FAST_POLL_INTERVAL } from '@/lib/constants'
+
+const RECEIVER_FAILURE_REASONS = [
+ 'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_FEE',
+ 'INVOICE_WRAPPING_FAILED_HIGH_PREDICTED_EXPIRY',
+ 'INVOICE_FORWARDING_CLTV_DELTA_TOO_LOW',
+ 'INVOICE_FORWARDING_FAILED'
+]
+
+export default function usePayInHelper () {
+ const client = useApolloClient()
+ const [retryPayIn] = useMutation(RETRY_PAY_IN)
+ const [cancelPayInBolt11] = useMutation(CANCEL_PAY_IN_BOLT11)
+
+ const check = useCallback(async (id, that, { query = GET_PAY_IN_RESULT, fetchPolicy = 'network-only' } = {}) => {
+ const { data, error } = await client.query({ query, fetchPolicy, variables: { id } })
+ if (error) {
+ throw error
+ }
+
+ const { payInBolt11, payInFailureReason, pessimisticEnv } = data.payIn
+ const { cancelledAt, expiresAt } = payInBolt11
+
+ const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt)
+ if (expired) {
+ throw new InvoiceExpiredError(payInBolt11)
+ }
+
+ if (RECEIVER_FAILURE_REASONS.includes(payInFailureReason)) {
+ throw new WalletReceiverError(payInBolt11)
+ }
+
+ const failed = cancelledAt || pessimisticEnv?.error
+ if (failed) {
+ throw new InvoiceCanceledError(payInBolt11, pessimisticEnv?.error)
+ }
+
+ return { payIn: data.payIn, check: that(data.payIn) }
+ }, [client])
+
+ const waitCheckController = useCallback((payInId) => {
+ return waitCheckPayInController(payInId, check)
+ }, [check])
+
+ const cancel = useCallback(async (payIn, { userCancel = false } = {}) => {
+ const { hash, hmac } = payIn.payInBolt11
+ console.log('canceling invoice:', hash)
+ const { data } = await cancelPayInBolt11({ variables: { hash, hmac, userCancel } })
+ return data.cancelPayInBolt11
+ }, [cancelPayInBolt11])
+
+ const retry = useCallback(async ({ payIn, newAttempt = false }, { update } = {}) => {
+ console.log('retrying invoice:', payIn.payInBolt11.hash)
+ const { data, error } = await retryPayIn({ variables: { payInId: payIn.id, newAttempt }, update })
+ if (error) throw error
+
+ const newPayIn = data.retryPayIn
+ console.log('new payIn:', newPayIn?.payInBolt11?.hash)
+
+ return newPayIn
+ }, [retryPayIn])
+
+ return useMemo(() => ({ cancel, retry, check, waitCheckController }), [cancel, retry, check, waitCheckController])
+}
+
+export class WaitCheckControllerAbortedError extends Error {
+ constructor (payInId) {
+ super(`waitCheckPayInController: aborted: ${payInId}`)
+ this.name = 'WaitCheckControllerAbortedError'
+ this.payInId = payInId
+ }
+}
+
+function waitCheckPayInController (payInId, check) {
+ const controller = new AbortController()
+ const signal = controller.signal
+ controller.wait = async (waitFor = payIn => payIn?.payInState === 'PAID', options) => {
+ console.log('waitCheckPayInController: wait', payInId)
+ let result
+ return await new Promise((resolve, reject) => {
+ const interval = setInterval(async () => {
+ try {
+ console.log('waitCheckPayInController: checking', payInId)
+ result = await check(payInId, waitFor, options)
+ console.log('waitCheckPayInController: checked', payInId, result)
+ if (result.check) {
+ resolve(result.payIn)
+ clearInterval(interval)
+ signal.removeEventListener('abort', abort)
+ } else {
+ console.info(`payIn #${payInId}: waiting for payment ...`)
+ }
+ } catch (err) {
+ console.log('waitCheckPayInController: error', payInId, err)
+ reject(err)
+ clearInterval(interval)
+ signal.removeEventListener('abort', abort)
+ }
+ }, FAST_POLL_INTERVAL)
+
+ const abort = () => {
+ console.info(`payIn #${payInId}: stopped waiting`)
+ result?.check ? resolve(result.payIn) : reject(new WaitCheckControllerAbortedError(payInId))
+ clearInterval(interval)
+ signal.removeEventListener('abort', abort)
+ }
+ signal.addEventListener('abort', abort)
+ })
+ }
+
+ controller.stop = () => controller.abort()
+
+ return controller
+}
diff --git a/components/payIn/hooks/use-pay-in-mutation.js b/components/payIn/hooks/use-pay-in-mutation.js
new file mode 100644
index 0000000000..6027c0ea44
--- /dev/null
+++ b/components/payIn/hooks/use-pay-in-mutation.js
@@ -0,0 +1,134 @@
+// if PENDING_HELD and not a zap, then it's pessimistic
+// if PENDING_HELD and a zap, it's optimistic unless the zapper is anon
+
+import { useCallback, useState } from 'react'
+import { InvoiceCanceledError } from '@/wallets/client/errors'
+import { useApolloClient, useMutation } from '@apollo/client'
+import usePayPayIn from '@/components/payIn/hooks/use-pay-pay-in'
+import { getOperationName } from '@apollo/client/utilities'
+import { useMe } from '@/components/me'
+
+/*
+this is just like useMutation with a few changes:
+1. pays an invoice returned by the mutation
+2. takes an onPaid and onPayError callback, and additional options for payment behavior
+ - namely forceWaitForPayment which will always wait for the invoice to be paid
+ - and persistOnNavigate which will keep the invoice in the cache after navigation
+3. onCompleted behaves a little differently, but analogously to useMutation, ie clientside side effects
+ of completion can still rely on it
+ a. it's called before the invoice is paid for optimistic updates
+ b. it's called after the invoice is paid for pessimistic updates
+4. we return a payError field in the result object if the invoice fails to pay
+*/
+export default function usePayInMutation (mutation, { onCompleted, ...options } = {}) {
+ if (options) {
+ options.optimisticResponse = addOptimisticResponseExtras(mutation, options.optimisticResponse)
+ }
+ const [mutate, result] = useMutation(mutation, options)
+ const client = useApolloClient()
+ const { me } = useMe()
+ // innerResult is used to store/control the result of the mutation when innerMutate runs
+ const [innerResult, setInnerResult] = useState(result)
+ const payPayIn = usePayPayIn()
+ const mutationName = getOperationName(mutation)
+
+ const innerMutate = useCallback(async ({ onCompleted: innerOnCompleted, ...innerOptions } = {}) => {
+ if (innerOptions) {
+ innerOptions.optimisticResponse = addOptimisticResponseExtras(mutation, innerOptions.optimisticResponse)
+ }
+ const { data, ...rest } = await mutate({ ...options, ...innerOptions })
+
+ // use the most inner callbacks/options if they exist
+ const {
+ onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
+ update, waitFor = payIn => payIn?.payInState === 'PAID', updateOnFallback
+ } = { ...options, ...innerOptions }
+ // onCompleted needs to run after the payIn is paid for pessimistic updates, so we give it special treatment
+ const ourOnCompleted = innerOnCompleted || onCompleted
+
+ const payIn = data[mutationName]
+
+ console.log('payInMutation', payIn)
+
+ // if the mutation returns in a pending state, it has an invoice we need to pay
+ let payError
+ if (payIn.payInState === 'PENDING' || payIn.payInState === 'PENDING_HELD') {
+ console.log('payInMutation: pending', payIn.payInState, payIn.payInType)
+ if (forceWaitForPayment || !me || (payIn.payInState === 'PENDING_HELD' && payIn.payInType !== 'ZAP')) {
+ console.log('payInMutation: forceWaitForPayment', forceWaitForPayment, me, payIn.payInState, payIn.payInType)
+ // the action is pessimistic
+ try {
+ // wait for the invoice to be paid
+ const paidPayIn = await payPayIn(payIn, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor, updateOnFallback })
+ console.log('payInMutation: paidPayIn', paidPayIn)
+ // we need to run update functions on mutations now that we have the data
+ const data = { [mutationName]: paidPayIn }
+ update?.(client.cache, { data })
+ ourOnCompleted?.(data)
+ onPaid?.(client.cache, { data })
+ } catch (e) {
+ console.error('usePayInMutation: failed to pay for pessimistic mutation', mutationName, e)
+ onPayError?.(e, client.cache, { data })
+ payError = e
+ }
+ } else {
+ console.log('payInMutation: not forceWaitForPayment', forceWaitForPayment, me, payIn.payInState, payIn.payInType)
+ // onCompleted is called before the invoice is paid for optimistic updates
+ ourOnCompleted?.(data)
+ // don't wait to pay the invoice
+ payPayIn(payIn, { persistOnNavigate, waitFor, updateOnFallback }).then((paidPayIn) => {
+ // invoice might have been retried during payment
+ onPaid?.(client.cache, { data: { [mutationName]: paidPayIn } })
+ }).catch(e => {
+ console.error('usePayInMutation: failed to pay for optimistic mutation', mutationName, e)
+ // onPayError is called after the invoice fails to pay
+ // useful for updating invoiceActionState to FAILED
+ onPayError?.(e, client.cache, { data })
+ payError = e
+ })
+ }
+ } else if (payIn.payInState === 'PAID') {
+ console.log('payInMutation: paid', payIn.payInState, payIn.payInType)
+ // fee credits/reward sats paid for it
+ ourOnCompleted?.(data)
+ onPaid?.(client.cache, { data })
+ } else {
+ console.log('payInMutation: unexpected', payIn.payInState, payIn.payInType)
+ payError = new Error(`PayIn is in an unexpected state: ${payIn.payInState}`)
+ }
+
+ const result = {
+ data,
+ payError,
+ ...rest,
+ error: payError instanceof InvoiceCanceledError && payError.actionError ? payError : undefined
+ }
+ setInnerResult(result)
+ return result
+ }, [mutate, options, payPayIn, client.cache, setInnerResult, !!me])
+
+ return [innerMutate, innerResult]
+}
+
+// all paid actions need these fields and they're easy to forget
+function addOptimisticResponseExtras (mutation, payInOptimisticResponse) {
+ if (!payInOptimisticResponse) return payInOptimisticResponse
+ const mutationName = getOperationName(mutation)
+ return {
+ [mutationName]: {
+ __typename: 'PayIn',
+ id: 'temp-pay-in-id',
+ payInBolt11: null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ payInState: 'PENDING',
+ payInStateChangedAt: new Date().toISOString(),
+ payInType: payInOptimisticResponse.payInType,
+ payInFailureReason: null,
+ payInCustodialTokens: null,
+ pessimisticEnv: null,
+ mcost: payInOptimisticResponse.mcost,
+ result: payInOptimisticResponse.result
+ }
+ }
+}
diff --git a/components/payIn/hooks/use-pay-pay-in.js b/components/payIn/hooks/use-pay-pay-in.js
new file mode 100644
index 0000000000..85dfa70188
--- /dev/null
+++ b/components/payIn/hooks/use-pay-pay-in.js
@@ -0,0 +1,48 @@
+import { useWalletPayment } from '@/wallets/client/hooks'
+import usePayInHelper from './use-pay-in-helper'
+import useQrPayIn from './use-qr-pay-in'
+import { useCallback } from 'react'
+import { WalletError, InvoiceCanceledError, InvoiceExpiredError, WalletPaymentError } from '@/wallets/client/errors'
+
+export default function usePayPayIn () {
+ const walletPayment = useWalletPayment()
+ const payInHelper = usePayInHelper()
+ const qrPayIn = useQrPayIn()
+ return useCallback(async (payIn, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor, updateOnFallback }) => {
+ let walletError
+ let walletInvoice = payIn.payInBolt11.bolt11
+ const start = Date.now()
+
+ try {
+ return await walletPayment(walletInvoice, { waitFor, updateOnFallback })
+ } catch (err) {
+ walletError = null
+ if (err instanceof WalletError) {
+ walletError = err
+ // get the last invoice that was attempted but failed and was canceled
+ if (err.invoice) walletInvoice = err.invoice
+ }
+
+ const invoiceError = err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError
+ if (!invoiceError && !walletError) {
+ // unexpected error, rethrow
+ throw err
+ }
+
+ // bail if the payment took too long to prevent showing a QR code on an unrelated page
+ // (if alwaysShowQROnFailure is not set) or user canceled the invoice or it expired
+ const tooSlow = Date.now() - start > 1000
+ const skipQr = (tooSlow && !alwaysShowQROnFailure) || invoiceError
+ if (skipQr) {
+ throw err
+ }
+ }
+
+ const paymentAttempted = walletError instanceof WalletPaymentError
+ if (paymentAttempted) {
+ walletInvoice = await payInHelper.retry(walletInvoice, { update: updateOnFallback })
+ }
+ console.log('usePayPayIn: qrPayIn', payIn.id, walletError)
+ return await qrPayIn(payIn, walletError, { persistOnNavigate, waitFor })
+ }, [payInHelper, qrPayIn, walletPayment])
+}
diff --git a/components/payIn/hooks/use-qr-pay-in.js b/components/payIn/hooks/use-qr-pay-in.js
new file mode 100644
index 0000000000..6913f17a19
--- /dev/null
+++ b/components/payIn/hooks/use-qr-pay-in.js
@@ -0,0 +1,99 @@
+import { useCallback } from 'react'
+import { AnonWalletError, InvoiceCanceledError } from '@/wallets/client/errors'
+import { useShowModal } from '@/components/modal'
+import usePayInHelper from '@/components/payIn/hooks/use-pay-in-helper'
+import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln'
+import useWatchPayIn from './use-watch-pay-in'
+import Qr, { QrSkeleton } from '@/components/qr'
+import PayInError from '../error'
+import { msatsToSats, numWithUnits } from '@/lib/format'
+import { PayInStatus } from '../status'
+
+export default function useQrPayIn () {
+ const payInHelper = usePayInHelper()
+ const showModal = useShowModal()
+
+ const waitForQrPayIn = useCallback(async (payIn, walletError,
+ {
+ keepOpen = true,
+ cancelOnClose = true,
+ persistOnNavigate = false,
+ waitFor = payIn => payIn?.payInState === 'PAID'
+ } = {}
+ ) => {
+ // if anon user and webln is available, try to pay with webln
+ if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) {
+ weblnSendPayment(payIn.payInBolt11.bolt11).catch(e => { console.error('WebLN payment failed:', e) })
+ }
+ return await new Promise((resolve, reject) => {
+ console.log('waitForQrPayIn', payIn.id, walletError)
+ let updatedPayIn
+ const cancelAndReject = async (onClose) => {
+ console.log('waitForQrPayIn: cancelAndReject', payIn.id, updatedPayIn, cancelOnClose)
+ if (!updatedPayIn && cancelOnClose) {
+ const updatedPayIn = await payInHelper.cancel(payIn, { userCancel: true })
+ reject(new InvoiceCanceledError(updatedPayIn?.payInBolt11))
+ }
+ resolve(updatedPayIn)
+ }
+ showModal(onClose =>
+
{
+ console.log('waitForQrPayIn: onPaymentError', err)
+ if (err instanceof InvoiceCanceledError) {
+ onClose()
+ reject(err)
+ } else {
+ reject(err)
+ }
+ }}
+ onPaymentSuccess={(payIn) => {
+ console.log('waitForQrPayIn: onPaymentSuccess', payIn)
+ updatedPayIn = payIn
+ // this onClose will resolve the promise before the subsequent line runs
+ // so we need to set updatedPayIn first
+ onClose()
+ resolve(payIn)
+ }}
+ />,
+ { keepOpen, persistOnNavigate, onClose: cancelAndReject })
+ })
+ }, [payInHelper])
+
+ return waitForQrPayIn
+}
+
+function QrPayIn ({
+ id, onPaymentError, onPaymentSuccess, waitFor, walletError
+}) {
+ const { data, error } = useWatchPayIn({ id, onPaymentError, onPaymentSuccess, waitFor })
+
+ const payIn = data?.payIn
+
+ if (error) {
+ return {error.message}
+ }
+
+ if (!payIn) {
+ return
+ }
+
+ const { bolt11 } = payIn.payInBolt11
+
+ return (
+ <>
+
+ 'lightning:' + value.toUpperCase()}
+ description={numWithUnits(msatsToSats(payIn.payInBolt11.msatsRequested), { abbreviate: false })}
+ />
+
+ >
+ )
+}
diff --git a/components/payIn/hooks/use-retry-pay-in.js b/components/payIn/hooks/use-retry-pay-in.js
new file mode 100644
index 0000000000..e83ca6c71e
--- /dev/null
+++ b/components/payIn/hooks/use-retry-pay-in.js
@@ -0,0 +1,35 @@
+import { RETRY_PAY_IN } from '@/fragments/payIn'
+import usePayInMutation from './use-pay-in-mutation'
+import { useAct } from '@/components/item-act'
+import { payBountyCacheMods } from '@/components/pay-bounty'
+
+export function useRetryPayIn (payInId, mutationOptions = {}) {
+ const [retryPayIn] = usePayInMutation(RETRY_PAY_IN, { ...mutationOptions, variables: { payInId } })
+ return retryPayIn
+}
+
+export function useRetryItemActPayIn (payInId, mutationOptions = {}) {
+ const retryPayIn = useAct({ query: RETRY_PAY_IN, ...mutationOptions, variables: { payInId } })
+ return retryPayIn
+}
+
+export function useRetryBountyPayIn (payInId, mutationOptions = {}) {
+ const options = {
+ ...mutationOptions,
+ ...payBountyCacheMods,
+ update: (cache, { data }) => {
+ payBountyCacheMods.update?.(cache, { data })
+ mutationOptions.update?.(cache, { data })
+ },
+ onPayError: (error, cache, { data }) => {
+ payBountyCacheMods.onPayError?.(error, cache, { data })
+ mutationOptions.onPayError?.(error, cache, { data })
+ },
+ onPaid: (cache, { data }) => {
+ payBountyCacheMods.onPaid?.(cache, { data })
+ mutationOptions.onPaid?.(cache, { data })
+ }
+ }
+ const retryPayIn = useAct({ query: RETRY_PAY_IN, ...options, variables: { payInId } })
+ return retryPayIn
+}
diff --git a/components/payIn/hooks/use-watch-pay-in.js b/components/payIn/hooks/use-watch-pay-in.js
new file mode 100644
index 0000000000..f18af7d51f
--- /dev/null
+++ b/components/payIn/hooks/use-watch-pay-in.js
@@ -0,0 +1,37 @@
+import { useQuery } from '@apollo/client'
+import { GET_PAY_IN_RESULT } from '@/fragments/payIn'
+import usePayInHelper, { WaitCheckControllerAbortedError } from './use-pay-in-helper'
+import { useEffect } from 'react'
+
+export default function useWatchPayIn ({ id, query = GET_PAY_IN_RESULT, onPaymentError, onPaymentSuccess, waitFor }) {
+ const payInHelper = usePayInHelper()
+
+ // we use the controller in a useEffect like this so we can reuse the same logic
+ // ... this controller is used in loops elsewhere where hooks are not allowed
+ useEffect(() => {
+ const controller = payInHelper.waitCheckController(id, waitFor, { query, fetchPolicy: 'cache-and-network' })
+
+ console.log('useWatchPayIn: useEffect', id)
+ const check = async () => {
+ console.log('useWatchPayIn: check', id)
+ try {
+ const payIn = await controller.wait()
+ console.log('useWatchPayIn: check: success', payIn)
+ onPaymentSuccess?.(payIn)
+ } catch (error) {
+ // check for error type so that we don't callback when the controller is stopped
+ // on unmount
+ if (!(error instanceof WaitCheckControllerAbortedError)) {
+ console.log('useWatchPayIn: check: error', error)
+ onPaymentError?.(error)
+ }
+ }
+ }
+ check()
+
+ return () => controller.stop()
+ }, [id, waitFor, payInHelper, onPaymentError, onPaymentSuccess])
+
+ // this will return the payIn in the cache as the useEffect updates the cache
+ return useQuery(query, { variables: { id } })
+}
diff --git a/components/payIn/index.js b/components/payIn/index.js
new file mode 100644
index 0000000000..b051aa7c95
--- /dev/null
+++ b/components/payIn/index.js
@@ -0,0 +1,73 @@
+import { msatsToSats, numWithUnits } from '@/lib/format'
+import Qr, { QrSkeleton } from '../qr'
+import Bolt11Info from './bolt11-info'
+import useWatchPayIn from './hooks/use-watch-pay-in'
+import { PayInStatus } from './status'
+import PayInMetadata from './metadata'
+import { describePayInType } from '@/lib/pay-in'
+import { useMe } from '../me'
+import { PayInContext } from './context'
+import { GET_PAY_IN_FULL } from '@/fragments/payIn'
+import { PayInSankey } from './sankey'
+
+export default function PayIn ({ id }) {
+ const { me } = useMe()
+ const { data, error } = useWatchPayIn({ id, query: GET_PAY_IN_FULL })
+
+ const payIn = data?.payIn
+
+ if (error) {
+ return {error.message}
+ }
+
+ if (!payIn) {
+ return
+ }
+
+ return (
+
+
+
+
{describePayInType(payIn, me.id)}
+
+
+
+ {new Date(payIn.createdAt).toLocaleString()}
+
+
+ {payIn.payInBolt11 &&
+ (
+ <>
+ {['PENDING', 'PENDING_HELD'].includes(payIn.payInState)
+ ? (
+
+
+ 'lightning:' + value.toUpperCase()}
+ description={numWithUnits(msatsToSats(payIn.payInBolt11.msatsRequested), { abbreviate: false })}
+ />
+
+
)
+ : (
+
+
lightning invoice
+
+
+ )}
+
+ >
+ )}
+
+
+
transaction diagram
+
+
+
+ )
+}
diff --git a/components/payIn/metadata.js b/components/payIn/metadata.js
new file mode 100644
index 0000000000..99097c6c15
--- /dev/null
+++ b/components/payIn/metadata.js
@@ -0,0 +1,59 @@
+import AccordianItem from '@/components/accordian-item'
+
+export function PayInMetadata ({ payInBolt11 }) {
+ const { nostrNote, lud18Data, comment } = payInBolt11
+
+ return (
+ <>
+
+ {nostrNote
+ ?
+
+ {JSON.stringify(nostrNote, null, 2)}
+
+
+ }
+ />
+ : null}
+
+ {lud18Data &&
+
+
}
+ className='mb-3'
+ />
+
}
+ {comment &&
+
+
{comment}}
+ className='mb-3'
+ />
+ }
+ >
+ )
+}
+
+export default function PayerData ({ data, className, header = false }) {
+ const supportedPayerData = ['name', 'pubkey', 'email', 'identifier']
+
+ if (!data) {
+ return null
+ }
+ return (
+
+ {header &&
sender information:}
+ {Object.entries(data)
+ // Don't display unsupported keys
+ .filter(([key]) => supportedPayerData.includes(key))
+ .map(([key, value]) => {
+ return
{value} ({key})
+ })}
+
+ )
+}
diff --git a/components/payIn/result.js b/components/payIn/result.js
new file mode 100644
index 0000000000..47eb939bfd
--- /dev/null
+++ b/components/payIn/result.js
@@ -0,0 +1,49 @@
+import { CommentFlat } from '@/components/comment'
+import Item from '@/components/item'
+import ItemJob from '@/components/item-job'
+import classNames from 'classnames'
+
+export default function PayInResult ({ payIn }) {
+ if (!payIn.result || !payIn.itemPayIn) return null
+
+ let className = 'text-info'
+ let actionString = ''
+
+ switch (payIn.payInState) {
+ case 'FAILED':
+ case 'RETRYING':
+ actionString += 'attempted '
+ className = 'text-warning'
+ break
+ case 'PAID':
+ actionString += 'successful '
+ className = 'text-success'
+ break
+ default:
+ actionString += 'pending '
+ }
+
+ switch (payIn.payInType) {
+ case 'ITEM_CREATE':
+ actionString += 'item creation'
+ break
+ case 'ZAP':
+ actionString += 'zap on item'
+ break
+ case 'DOWN_ZAP':
+ actionString += 'downzap on item'
+ break
+ case 'POLL_VOTE':
+ actionString += 'poll vote'
+ break
+ }
+
+ return (
+
+
{actionString}
+ {(payIn.payInItem?.item?.isJob &&
) ||
+ (payIn.payInItem?.item?.title &&
) ||
+
}
+
+ )
+}
diff --git a/components/payIn/sankey.js b/components/payIn/sankey.js
new file mode 100644
index 0000000000..d5edcb3156
--- /dev/null
+++ b/components/payIn/sankey.js
@@ -0,0 +1,205 @@
+import { msatsToSatsDecimal, numWithUnits } from '@/lib/format'
+import { ResponsiveSankey, SankeyLabelComponent } from '@nivo/sankey'
+
+export function PayInSankey ({ payIn }) {
+ const data = getSankeyData(payIn)
+ return (
+
+
+
+ )
+}
+
+function assetFormatted (msats, type) {
+ if (type === 'CREDITS') {
+ return numWithUnits(msatsToSatsDecimal(msats), { unitSingular: 'CC', unitPlural: 'CCs', abbreviate: false })
+ }
+ return numWithUnits(msatsToSatsDecimal(msats), { unitSingular: 'sat', unitPlural: 'sats', abbreviate: false })
+}
+
+function Tooltip ({ node, link }) {
+ node ??= link
+ if (!node.asset) {
+ return null
+ }
+ return {node.asset}
+}
+
+function getSankeyData (payIn) {
+ const nodes = []
+ const links = []
+
+ // stacker news is always a node
+ nodes.push({
+ id: ''
+ })
+
+ // Create individual nodes for each payInCustodialToken
+ if (payIn.payInCustodialTokens && payIn.payInCustodialTokens.length > 0) {
+ payIn.payInCustodialTokens.forEach((token, index) => {
+ const id = token.custodialTokenType === 'SATS' ? 'sats' : 'CCs'
+ nodes.push({
+ id,
+ mtokens: token.mtokens,
+ custodialTokenType: token.custodialTokenType
+ })
+ links.push({
+ source: id,
+ target: '',
+ mtokens: token.mtokens,
+ custodialTokenType: token.custodialTokenType
+ })
+ })
+ }
+
+ // Create node for payInBolt11 if it exists
+ if (payIn.payInBolt11) {
+ nodes.push({
+ id: 'lightning',
+ mtokens: payIn.payInBolt11.msatsRequested,
+ custodialTokenType: 'SATS'
+ })
+
+ let leftOverMsats = payIn.payInBolt11.msatsRequested
+
+ // this is a p2p zap or payment
+ if (payIn.payOutBolt11) {
+ leftOverMsats = payIn.payInBolt11.msatsRequested - payIn.payOutBolt11.msats
+ let id = 'lightning (out)'
+ if (payIn.payOutBolt11) {
+ if (payIn.payOutBolt11.user?.name) {
+ id = `@${payIn.payOutBolt11.user.name}`
+ }
+
+ nodes.push({
+ id,
+ mtokens: payIn.payOutBolt11.msats,
+ custodialTokenType: 'SATS'
+ })
+
+ links.push({
+ source: 'lightning',
+ target: id,
+ mtokens: payIn.payOutBolt11.msats,
+ custodialTokenType: 'SATS'
+ })
+ }
+ }
+
+ if (leftOverMsats > 0) {
+ links.push({
+ source: 'lightning',
+ target: '',
+ mtokens: leftOverMsats,
+ custodialTokenType: 'SATS'
+ })
+ }
+ } else if (payIn.payOutBolt11) {
+ // this is a withdrawal
+ nodes.push({
+ id: 'lightning (out)',
+ mtokens: payIn.payOutBolt11.msats,
+ custodialTokenType: 'SATS'
+ })
+
+ links.push({
+ source: '',
+ target: 'lightning (out)',
+ mtokens: payIn.payOutBolt11.msats,
+ custodialTokenType: 'SATS'
+ })
+ }
+
+ // Create individual nodes for each payOutCustodialToken
+ if (payIn.payOutCustodialTokens && payIn.payOutCustodialTokens.length > 0) {
+ payIn.payOutCustodialTokens.forEach((token, index) => {
+ let id = token.payOutType.toLowerCase().replace('_', ' ')
+ if (token.payOutType === 'TERRITORY_REVENUE' && token.sub?.name) {
+ id = `~${token.sub.name}`
+ } else if (token.payOutType === 'ZAP' && token.user?.name) {
+ id = `@${token.user.name}`
+ } else if (token.payOutType === 'ROUTING_FEE') {
+ id = 'route'
+ } else if (token.payOutType === 'ROUTING_FEE_REFUND') {
+ id = 'refund'
+ } else if (token.payOutType === 'REWARDS_POOL') {
+ id = 'rewards'
+ }
+
+ nodes.push({
+ id,
+ mtokens: token.mtokens,
+ custodialTokenType: token.custodialTokenType
+ })
+ links.push({
+ source: '',
+ target: id,
+ mtokens: token.mtokens,
+ custodialTokenType: token.custodialTokenType
+ })
+ })
+ }
+
+ return reduceLinksAndNodes({ nodes, links })
+}
+
+// combine duplicate nodes and links, adding the mtokens together
+function reduceLinksAndNodes ({ links, nodes }) {
+ const reducedLinks = []
+ const reducedNodes = []
+
+ nodes.forEach(node => {
+ const existingNode = reducedNodes.find(n => n.id === node.id)
+ if (existingNode) {
+ existingNode.mtokens += node.mtokens
+ } else {
+ reducedNodes.push(node)
+ }
+ })
+
+ reducedNodes.forEach(node => {
+ if (node.mtokens) {
+ node.asset = assetFormatted(node.mtokens, node.custodialTokenType)
+ }
+ })
+
+ links.forEach(link => {
+ const existingLink = reducedLinks.find(l => l.source === link.source && l.target === link.target)
+ if (existingLink) {
+ existingLink.mtokens += link.mtokens
+ } else {
+ reducedLinks.push(link)
+ }
+ })
+
+ reducedLinks.forEach(link => {
+ link.value = msatsToSatsDecimal(link.mtokens)
+ link.asset = assetFormatted(link.mtokens, link.custodialTokenType)
+ })
+
+ return { links: reducedLinks, nodes: reducedNodes }
+}
diff --git a/components/payIn/status.js b/components/payIn/status.js
new file mode 100644
index 0000000000..86c5284799
--- /dev/null
+++ b/components/payIn/status.js
@@ -0,0 +1,23 @@
+import CompactLongCountdown from '@/components/countdown'
+import Moon from '@/svgs/moon-fill.svg'
+import Check from '@/svgs/check-double-line.svg'
+import ThumbDown from '@/svgs/thumb-down-fill.svg'
+
+const statusIconSize = 16
+
+export function PayInStatus ({ payIn }) {
+ function StatusText ({ color, children }) {
+ return (
+ {children}
+ )
+ }
+
+ return (
+
+ {(payIn.payInState === 'PAID' && <>{payIn.mcost > 0 ? 'paid' : 'free'}>) ||
+ ((payIn.payInState === 'FAILED' || payIn.payInState === 'CANCELLED' || payIn.payInState === 'FORWARD_FAILED') && <>failed>) ||
+ ((payIn.payInState === 'FORWARDING' || payIn.payInState === 'FORWARDED' || !payIn.payInBolt11) && <>settling>) ||
+ ()}
+
+ )
+}
diff --git a/components/payIn/table/index.js b/components/payIn/table/index.js
new file mode 100644
index 0000000000..e590f182b9
--- /dev/null
+++ b/components/payIn/table/index.js
@@ -0,0 +1,46 @@
+import styles from '../table/index.module.css'
+import { useMe } from '../../me'
+import classNames from 'classnames'
+import { PayInType } from './type'
+import { PayInContext } from '../context'
+import { PayInMoney } from './money'
+import LinkToContext from '@/components/link-to-context'
+
+export default function PayInTable ({ payIns }) {
+ return (
+
+
+
type
+
context
+
sats
+
+ {payIns?.map(payIn => (
+
+ ))}
+
+ )
+}
+
+function PayInRow ({ payIn }) {
+ const { me } = useMe()
+
+ return (
+
+ )
+}
diff --git a/components/payIn/table/index.module.css b/components/payIn/table/index.module.css
new file mode 100644
index 0000000000..e8a978ed32
--- /dev/null
+++ b/components/payIn/table/index.module.css
@@ -0,0 +1,79 @@
+.table {
+ display: grid;
+ grid-template-columns: max-content 1fr max-content;
+}
+
+.row {
+ display: contents;
+}
+
+.row.header {
+ text-transform: uppercase;
+ font-weight: 600;
+ font-size: 0.8rem;
+ color: var(--theme-mutedTextColor);
+ text-align: center;
+}
+
+.row.header > *:first-child {
+ text-align: left;
+}
+
+.row.header > *:last-child {
+ text-align: right;
+}
+
+.row:hover:not(.header) > * {
+ background-color: var(--theme-clickToContextColor);
+}
+
+.row > * {
+ padding: 10px 10px;
+}
+
+.row.failed > * {
+ opacity: 0.75;
+ background-color:rgba(255, 0, 0, 0.05);
+}
+
+.row.failed:hover > * {
+ background-color: rgba(255, 0, 0, 0.1);
+}
+
+.row.stacking:not(.failed) > * {
+ background-color:rgba(0, 255, 0, 0.05);
+}
+
+.row.stacking:not(.failed):hover > * {
+ background-color:rgba(0, 255, 0, 0.1);
+}
+
+.type {
+ text-align: left;
+ display: grid;
+ grid-template-rows: 1fr 1fr 1fr;
+ align-items: center;
+ gap: 0px;
+}
+
+.context {
+ display: flex;
+ align-items: center;
+}
+
+.money {
+ display: grid;
+ align-items: center;
+ text-align: right;
+ line-height: 1.1;
+}
+
+.money > * {
+ padding: .2rem 0;
+}
+
+.sats {
+ text-align: right;
+ display: grid;
+ align-items: center;
+}
\ No newline at end of file
diff --git a/components/payIn/table/money.js b/components/payIn/table/money.js
new file mode 100644
index 0000000000..6443ce8438
--- /dev/null
+++ b/components/payIn/table/money.js
@@ -0,0 +1,84 @@
+import { useMemo } from 'react'
+import { useMe } from '../../me'
+import { isNumber, numWithUnits, msatsToSats } from '@/lib/format'
+import Plug from '@/svgs/plug.svg'
+
+export function PayInMoney ({ payIn }) {
+ const { me } = useMe()
+ const { SATS, CREDITS } = useMemo(() => reduceCustodialTokenCosts(payIn, me.id), [payIn, me.id])
+ const bolt11Cost = useMemo(() => reduceBolt11Cost(payIn, me.id), [payIn, me.id])
+
+ if (payIn.mcost === 0 || payIn.payInState === 'FAILED' || (Number(payIn.userId) !== Number(me.id) && payIn.payInState !== 'PAID')) {
+ return <>N/A>
+ }
+
+ return (
+ <>
+ {isNumber(SATS?.mtokens) && SATS.mtokens !== 0 && }
+ {isNumber(CREDITS?.mtokens) && CREDITS.mtokens !== 0 && }
+ {isNumber(bolt11Cost) && bolt11Cost !== 0 && {formatCost(bolt11Cost, 'sat', 'sats')}
}
+ >
+ )
+}
+
+function Money ({ mtokens, mtokensAfter, singular, plural }) {
+ return (
+
+
{formatCost(mtokens, singular, plural)}
+ {isNumber(mtokensAfter) &&
{numWithUnits(msatsToSats(mtokensAfter), { unitSingular: singular, unitPlural: plural, abbreviate: false })}}
+
+ )
+}
+
+function formatCost (mtokens, unitSingular, unitPlural) {
+ let sign = ''
+ if (mtokens < 0) {
+ mtokens = -mtokens
+ } else {
+ sign = '+'
+ }
+
+ return `${sign}${numWithUnits(msatsToSats(mtokens), { unitSingular, unitPlural, abbreviate: false })}`
+}
+
+function reduceBolt11Cost (payIn, userId) {
+ console.log('reduceBolt11Cost', payIn, userId)
+ let cost = 0
+ if (Number(payIn.userId) === Number(userId) && payIn.payInBolt11) {
+ cost -= payIn.payInBolt11.msatsReceived
+ }
+ if (Number(payIn.payOutBolt11?.userId) === Number(userId)) {
+ cost += payIn.payOutBolt11.msats
+ }
+ return cost
+}
+
+function reduceCustodialTokenCosts (payIn, userId) {
+ // on a payin, the mtokensAfter is going to be the maximum
+ const payInCosts = payIn.payInCustodialTokens?.reduce((acc, token) => {
+ if (Number(payIn.userId) !== Number(userId)) {
+ return acc
+ }
+
+ acc[token.custodialTokenType] = {
+ mtokens: acc[token.custodialTokenType]?.mtokens - token.mtokens,
+ mtokensAfter: acc[token.custodialTokenType]?.mtokensAfter ? Math.min(acc[token.custodialTokenType].mtokensAfter, token.mtokensAfter) : token.mtokensAfter
+ }
+ return acc
+ }, { SATS: { mtokens: 0, mtokensAfter: null }, CREDITS: { mtokens: 0, mtokensAfter: null } })
+
+ // on a payout, the mtokensAfter is going to be the maximum
+ const totalCost = payIn.payOutCustodialTokens?.reduce((acc, token) => {
+ if (Number(token.userId) !== Number(userId)) {
+ return acc
+ }
+
+ acc[token.custodialTokenType] = {
+ mtokens: acc[token.custodialTokenType]?.mtokens + token.mtokens,
+ mtokensAfter: acc[token.custodialTokenType]?.mtokensAfter ? Math.max(acc[token.custodialTokenType].mtokensAfter, token.mtokensAfter) : token.mtokensAfter
+ }
+ return acc
+ }, { ...payInCosts })
+
+ return totalCost
+}
diff --git a/components/payIn/table/type.js b/components/payIn/table/type.js
new file mode 100644
index 0000000000..0ad4357730
--- /dev/null
+++ b/components/payIn/table/type.js
@@ -0,0 +1,19 @@
+import { timeSince } from '@/lib/time'
+import { useMe } from '@/components/me'
+import { describePayInType } from '@/lib/pay-in'
+import { PayInStatus } from '../status'
+
+export function PayInType ({ payIn }) {
+ return (
+ <>
+ {timeSince(new Date(payIn.payInStateChangedAt))}
+
+
+ >
+ )
+}
+
+function PayInTypeShortDescription ({ payIn }) {
+ const { me } = useMe()
+ return {describePayInType(payIn, me.id)}
+}
diff --git a/components/payer-data.js b/components/payer-data.js
index 64c8f77bbb..9fd0f44473 100644
--- a/components/payer-data.js
+++ b/components/payer-data.js
@@ -16,3 +16,5 @@ export default function PayerData ({ data, className, header = false }) {
)
}
+
+// TODO: delete when payIn is finished
diff --git a/components/poll-form.js b/components/poll-form.js
index cab8a45d55..0899aea960 100644
--- a/components/poll-form.js
+++ b/components/poll-form.js
@@ -9,7 +9,7 @@ import { SubSelectInitial } from './sub-select'
import { normalizeForwards } from '@/lib/form'
import { useMe } from './me'
import { ItemButtonBar } from './post'
-import { UPSERT_POLL } from '@/fragments/paidAction'
+import { UPSERT_POLL } from '@/fragments/payIn'
import useItemSubmit from './use-item-submit'
export function PollForm ({ item, sub, editThreshold, children }) {
diff --git a/components/poll.js b/components/poll.js
index d02283335e..13566a178c 100644
--- a/components/poll.js
+++ b/components/poll.js
@@ -5,79 +5,60 @@ import { useMe } from './me'
import styles from './poll.module.css'
import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip'
-import useQrPayment from './use-qr-payment'
import { useToast } from './toast'
-import { usePaidMutation } from './use-paid-mutation'
-import { POLL_VOTE, RETRY_PAID_ACTION } from '@/fragments/paidAction'
+import usePayInMutation from '@/components/payIn/hooks/use-pay-in-mutation'
+import { POLL_VOTE } from '@/fragments/payIn'
+import { useState } from 'react'
+import classNames from 'classnames'
-export default function Poll ({ item }) {
- const { me } = useMe()
+const PollButton = ({ v, item }) => {
const pollVote = usePollVote({ query: POLL_VOTE, itemId: item.id })
+ const [isSubmitting, setIsSubmitting] = useState(false)
const toaster = useToast()
+ const { me } = useMe()
- const PollButton = ({ v }) => {
- return (
-
+ )
+}
+
+export default function Poll ({ item }) {
+ const { me } = useMe()
const hasExpiration = !!item.pollExpiresAt
const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
const mine = item.user.id === me?.id
- const meVotePending = item.poll.meInvoiceActionState && item.poll.meInvoiceActionState !== 'PAID'
- const showPollButton = me && (!hasExpiration || timeRemaining) && !item.poll.meVoted && !meVotePending && !mine
+ const showPollButton = me && (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine
const pollCount = item.poll.count
return (