Compare commits
50 Commits
707d7bdf8b
...
909853521d
Author | SHA1 | Date |
---|---|---|
k00b | 909853521d | |
ekzyis | 01d5177006 | |
k00b | 8595a2b8b0 | |
Riccardo Balbo | 7f11792111 | |
soxa | 76e384b188 | |
soxa | 0b97d2ae94 | |
Lorenzo | d88971e8e5 | |
ekzyis | 0837460c53 | |
Felipe Bueno | 55d1f2c952 | |
Lorenzo | bd5db1b62e | |
Lorenzo | 7cb2aed9db | |
ekzyis | 8ce2e46519 | |
k00b | 9caeca00df | |
ekzyis | 799a39b75f | |
k00b | 404cf188b3 | |
k00b | 105f7b07e5 | |
k00b | cb028d217c | |
k00b | 6b59e1fa75 | |
k00b | f89286ffdf | |
ekzyis | 6c3301a9c4 | |
k00b | 67f6c170aa | |
ekzyis | f9169c645a | |
ekzyis | a0d33a23f3 | |
k00b | b608fb6848 | |
Keyan | 61395a3525 | |
ekzyis | 5e76ee1844 | |
ekzyis | 7a8db53ecf | |
ekzyis | b301b31a46 | |
ekzyis | 9cfc18d655 | |
ekzyis | 68513559e4 | |
ekzyis | a4144d4fcc | |
ekzyis | 0051c82415 | |
ekzyis | 14a92ee5ce | |
ekzyis | b1cdd953a0 | |
ekzyis | 7e25e29507 | |
ekzyis | 7f5bb33073 | |
ekzyis | be4ce5daf9 | |
ekzyis | 1f2b717da9 | |
ekzyis | 00f9e05dd7 | |
ekzyis | 974e897753 | |
ekzyis | 517d9a9bb9 | |
ekzyis | 413f76c33a | |
ekzyis | ed82d9cfc0 | |
ekzyis | d99caa43fc | |
ekzyis | 7036804c67 | |
ekzyis | 7742257470 | |
ekzyis | bc0c6d1038 | |
ekzyis | 5218a03b3a | |
ekzyis | 2b47bf527b | |
ekzyis | 35159bf7f3 |
|
@ -304,28 +304,43 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
||||||
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
|
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) {
|
|
||||||
throw new Error(`retryPaidAction - action does not support optimism ${actionType}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!action.retry) {
|
|
||||||
throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!failedInvoice) {
|
if (!failedInvoice) {
|
||||||
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
|
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { msatsRequested, actionId, actionArgs } = failedInvoice
|
const { msatsRequested, actionId, actionArgs, actionOptimistic } = failedInvoice
|
||||||
const retryContext = {
|
const retryContext = {
|
||||||
...incomingContext,
|
...incomingContext,
|
||||||
optimistic: true,
|
optimistic: actionOptimistic,
|
||||||
me: await models.user.findUnique({ where: { id: me.id } }),
|
me: await models.user.findUnique({ where: { id: me.id } }),
|
||||||
cost: BigInt(msatsRequested),
|
cost: BigInt(msatsRequested),
|
||||||
actionId
|
actionId
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
|
let invoiceArgs
|
||||||
|
const invoiceForward = await models.invoiceForward.findUnique({
|
||||||
|
where: { invoiceId: failedInvoice.id },
|
||||||
|
include: {
|
||||||
|
wallet: true,
|
||||||
|
invoice: true,
|
||||||
|
withdrawl: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// TODO: receiver fallbacks
|
||||||
|
// use next receiver wallet if forward failed (we currently immediately fallback to SN)
|
||||||
|
const failedForward = invoiceForward?.withdrawl && invoiceForward.withdrawl.actionState !== 'CONFIRMED'
|
||||||
|
if (invoiceForward && !failedForward) {
|
||||||
|
const { userId } = invoiceForward.wallet
|
||||||
|
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
|
||||||
|
msats: failedInvoice.msatsRequested,
|
||||||
|
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
|
||||||
|
description: await action.describe?.(actionArgs, retryContext),
|
||||||
|
expiry: INVOICE_EXPIRE_SECS
|
||||||
|
}, retryContext)
|
||||||
|
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
|
||||||
|
} else {
|
||||||
|
invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
|
||||||
|
}
|
||||||
|
|
||||||
return await models.$transaction(async tx => {
|
return await models.$transaction(async tx => {
|
||||||
const context = { ...retryContext, tx, invoiceArgs }
|
const context = { ...retryContext, tx, invoiceArgs }
|
||||||
|
@ -345,9 +360,9 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
||||||
const invoice = await createDbInvoice(actionType, actionArgs, context)
|
const invoice = await createDbInvoice(actionType, actionArgs, context)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
|
result: await action.retry?.({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
|
||||||
invoice,
|
invoice,
|
||||||
paymentMethod: 'OPTIMISTIC'
|
paymentMethod: actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
|
||||||
}
|
}
|
||||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { inviteSchema, validateSchema } from '@/lib/validate'
|
import { inviteSchema, validateSchema } from '@/lib/validate'
|
||||||
import { msatsToSats } from '@/lib/format'
|
import { msatsToSats } from '@/lib/format'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
import { GqlAuthenticationError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
|
@ -9,7 +10,6 @@ export default {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GqlAuthenticationError()
|
throw new GqlAuthenticationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
return await models.invite.findMany({
|
return await models.invite.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: me.id
|
userId: me.id
|
||||||
|
@ -29,27 +29,48 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
Mutation: {
|
||||||
createInvite: async (parent, { gift, limit }, { me, models }) => {
|
createInvite: async (parent, { id, gift, limit, description }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GqlAuthenticationError()
|
throw new GqlAuthenticationError()
|
||||||
}
|
}
|
||||||
assertApiKeyNotPermitted({ me })
|
assertApiKeyNotPermitted({ me })
|
||||||
|
|
||||||
await validateSchema(inviteSchema, { gift, limit })
|
await validateSchema(inviteSchema, { id, gift, limit, description })
|
||||||
|
try {
|
||||||
return await models.invite.create({
|
return await models.invite.create({
|
||||||
data: { gift, limit, userId: me.id }
|
data: {
|
||||||
})
|
id,
|
||||||
|
gift,
|
||||||
|
limit,
|
||||||
|
userId: me.id,
|
||||||
|
description
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (error.code === 'P2002' && error.meta.target.includes('id')) {
|
||||||
|
throw new GqlInputError('an invite with this code already exists')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
},
|
},
|
||||||
revokeInvite: async (parent, { id }, { me, models }) => {
|
revokeInvite: async (parent, { id }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GqlAuthenticationError()
|
throw new GqlAuthenticationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
return await models.invite.update({
|
try {
|
||||||
where: { id },
|
return await models.invite.update({
|
||||||
data: { revoked: true }
|
where: { id, userId: me.id },
|
||||||
})
|
data: { revoked: true }
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'P2025') {
|
||||||
|
throw new GqlInputError('invite not found')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -63,6 +84,9 @@ export default {
|
||||||
poor: async (invite, args, { me, models }) => {
|
poor: async (invite, args, { me, models }) => {
|
||||||
const user = await models.user.findUnique({ where: { id: invite.userId } })
|
const user = await models.user.findUnique({ where: { id: invite.userId } })
|
||||||
return msatsToSats(user.msats) < invite.gift
|
return msatsToSats(user.msats) < invite.gift
|
||||||
|
},
|
||||||
|
description: (invite, args, { me }) => {
|
||||||
|
return invite.userId === me?.id ? invite.description : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -300,6 +300,8 @@ function typeClause (type) {
|
||||||
return ['"Item".bio = true', '"Item"."parentId" IS NULL']
|
return ['"Item".bio = true', '"Item"."parentId" IS NULL']
|
||||||
case 'bounties':
|
case 'bounties':
|
||||||
return ['"Item".bounty IS NOT NULL', '"Item"."parentId" IS NULL']
|
return ['"Item".bounty IS NOT NULL', '"Item"."parentId" IS NULL']
|
||||||
|
case 'bounties_active':
|
||||||
|
return ['"Item".bounty IS NOT NULL', '"Item"."parentId" IS NULL', '"Item"."bountyPaidTo" IS NULL']
|
||||||
case 'comments':
|
case 'comments':
|
||||||
return '"Item"."parentId" IS NOT NULL'
|
return '"Item"."parentId" IS NOT NULL'
|
||||||
case 'freebies':
|
case 'freebies':
|
||||||
|
@ -423,6 +425,7 @@ export default {
|
||||||
subClause(sub, 5, subClauseTable(type), me, showNsfw),
|
subClause(sub, 5, subClauseTable(type), me, showNsfw),
|
||||||
typeClause(type),
|
typeClause(type),
|
||||||
whenClause(when, 'Item'),
|
whenClause(when, 'Item'),
|
||||||
|
activeOrMine(me),
|
||||||
await filterClause(me, models, type),
|
await filterClause(me, models, type),
|
||||||
by === 'boost' && '"Item".boost > 0',
|
by === 'boost' && '"Item".boost > 0',
|
||||||
muteClause(me))}
|
muteClause(me))}
|
||||||
|
|
|
@ -13,6 +13,8 @@ import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
|
||||||
import { CHAIN_FEE } from '@/fragments/chainFee'
|
import { CHAIN_FEE } from '@/fragments/chainFee'
|
||||||
import { getServerSession } from 'next-auth/next'
|
import { getServerSession } from 'next-auth/next'
|
||||||
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
|
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
|
||||||
|
import { NOFOLLOW_LIMIT } from '@/lib/constants'
|
||||||
|
import { satsToMsats } from '@/lib/format'
|
||||||
|
|
||||||
export default async function getSSRApolloClient ({ req, res, me = null }) {
|
export default async function getSSRApolloClient ({ req, res, me = null }) {
|
||||||
const session = req && await getServerSession(req, res, getAuthOptions(req))
|
const session = req && await getServerSession(req, res, getAuthOptions(req))
|
||||||
|
@ -64,7 +66,17 @@ function oneDayReferral (request, { me }) {
|
||||||
let prismaPromise, getData
|
let prismaPromise, getData
|
||||||
|
|
||||||
if (referrer.startsWith('item-')) {
|
if (referrer.startsWith('item-')) {
|
||||||
prismaPromise = models.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } })
|
prismaPromise = models.item.findUnique({
|
||||||
|
where: {
|
||||||
|
id: parseInt(referrer.slice(5)),
|
||||||
|
msats: {
|
||||||
|
gt: satsToMsats(NOFOLLOW_LIMIT)
|
||||||
|
},
|
||||||
|
weightedVotes: {
|
||||||
|
gt: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
getData = item => ({
|
getData = item => ({
|
||||||
referrerId: item.userId,
|
referrerId: item.userId,
|
||||||
refereeId: parseInt(me.id),
|
refereeId: parseInt(me.id),
|
||||||
|
|
|
@ -7,7 +7,7 @@ export default gql`
|
||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
createInvite(gift: Int!, limit: Int): Invite
|
createInvite(id: String, gift: Int!, limit: Int, description: String): Invite
|
||||||
revokeInvite(id: ID!): Invite
|
revokeInvite(id: ID!): Invite
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,5 +20,6 @@ export default gql`
|
||||||
user: User!
|
user: User!
|
||||||
revoked: Boolean!
|
revoked: Boolean!
|
||||||
poor: Boolean!
|
poor: Boolean!
|
||||||
|
description: String
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
|
@ -106,7 +106,11 @@ export const FileUpload = forwardRef(({ children, className, onSelect, onUpload,
|
||||||
if (onSelect) await onSelect?.(file, s3Upload)
|
if (onSelect) await onSelect?.(file, s3Upload)
|
||||||
else await s3Upload(file)
|
else await s3Upload(file)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toaster.danger(`upload of '${file.name}' failed: ` + e.message || e.toString?.())
|
if (file.type === 'video/quicktime') {
|
||||||
|
toaster.danger(`upload of '${file.name}' failed: codec might not be supported, check video settings`)
|
||||||
|
} else {
|
||||||
|
toaster.danger(`upload of '${file.name}' failed: ` + e.message || e.toString?.())
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { CopyInput } from './form'
|
||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import { INVITE_FIELDS } from '@/fragments/invites'
|
import { INVITE_FIELDS } from '@/fragments/invites'
|
||||||
import styles from '@/styles/invites.module.css'
|
import styles from '@/styles/invites.module.css'
|
||||||
|
import { useToast } from '@/components/toast'
|
||||||
|
|
||||||
export default function Invite ({ invite, active }) {
|
export default function Invite ({ invite, active }) {
|
||||||
const [revokeInvite] = useMutation(
|
const [revokeInvite] = useMutation(
|
||||||
|
@ -13,11 +14,13 @@ export default function Invite ({ invite, active }) {
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
|
const toaster = useToast()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.invite}
|
className={styles.invite}
|
||||||
>
|
>
|
||||||
|
{invite.description && <small className='text-muted'>{invite.description}</small>}
|
||||||
<CopyInput
|
<CopyInput
|
||||||
groupClassName='mb-1'
|
groupClassName='mb-1'
|
||||||
size='sm' type='text'
|
size='sm' type='text'
|
||||||
|
@ -33,7 +36,13 @@ export default function Invite ({ invite, active }) {
|
||||||
<span> \ </span>
|
<span> \ </span>
|
||||||
<span
|
<span
|
||||||
className={styles.revoke}
|
className={styles.revoke}
|
||||||
onClick={() => revokeInvite({ variables: { id: invite.id } })}
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await revokeInvite({ variables: { id: invite.id } })
|
||||||
|
} catch (err) {
|
||||||
|
toaster.danger(err.message)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>revoke
|
>revoke
|
||||||
</span>
|
</span>
|
||||||
</>)
|
</>)
|
||||||
|
|
|
@ -8,7 +8,7 @@ import Bolt11Info from './bolt11-info'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { INVOICE } from '@/fragments/wallet'
|
import { INVOICE } from '@/fragments/wallet'
|
||||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||||
import { NoAttachedWalletError } from '@/wallets/errors'
|
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/errors'
|
||||||
import ItemJob from './item-job'
|
import ItemJob from './item-job'
|
||||||
import Item from './item'
|
import Item from './item'
|
||||||
import { CommentFlat } from './comment'
|
import { CommentFlat } from './comment'
|
||||||
|
@ -19,7 +19,7 @@ import styles from './invoice.module.css'
|
||||||
|
|
||||||
export default function Invoice ({
|
export default function Invoice ({
|
||||||
id, query = INVOICE, modal, onPayment, onExpired, onCanceled, info, successVerb = 'deposited',
|
id, query = INVOICE, modal, onPayment, onExpired, onCanceled, info, successVerb = 'deposited',
|
||||||
heldVerb = 'settling', useWallet = true, walletError, poll, waitFor, ...props
|
heldVerb = 'settling', walletError, poll, waitFor, ...props
|
||||||
}) {
|
}) {
|
||||||
const { data, error } = useQuery(query, SSR
|
const { data, error } = useQuery(query, SSR
|
||||||
? {}
|
? {}
|
||||||
|
@ -79,15 +79,12 @@ export default function Invoice ({
|
||||||
{invoice.forwardedSats && <Badge className={styles.badge} bg={null}>p2p</Badge>}
|
{invoice.forwardedSats && <Badge className={styles.badge} bg={null}>p2p</Badge>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
useWallet = false
|
|
||||||
} else if (expired) {
|
} else if (expired) {
|
||||||
variant = 'failed'
|
variant = 'failed'
|
||||||
status = 'expired'
|
status = 'expired'
|
||||||
useWallet = false
|
|
||||||
} else if (invoice.cancelled) {
|
} else if (invoice.cancelled) {
|
||||||
variant = 'failed'
|
variant = 'failed'
|
||||||
status = 'cancelled'
|
status = 'cancelled'
|
||||||
useWallet = false
|
|
||||||
} else if (invoice.isHeld) {
|
} else if (invoice.isHeld) {
|
||||||
variant = 'pending'
|
variant = 'pending'
|
||||||
status = (
|
status = (
|
||||||
|
@ -95,7 +92,6 @@ export default function Invoice ({
|
||||||
<Moon className='spin fill-grey me-2' /> {heldVerb}
|
<Moon className='spin fill-grey me-2' /> {heldVerb}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
useWallet = false
|
|
||||||
} else {
|
} else {
|
||||||
variant = 'pending'
|
variant = 'pending'
|
||||||
status = (
|
status = (
|
||||||
|
@ -107,13 +103,9 @@ export default function Invoice ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{walletError && !(walletError instanceof NoAttachedWalletError) &&
|
<WalletError error={walletError} />
|
||||||
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
|
|
||||||
Paying from attached wallet failed:
|
|
||||||
<code> {walletError.message}</code>
|
|
||||||
</div>}
|
|
||||||
<Qr
|
<Qr
|
||||||
useWallet={useWallet} value={invoice.bolt11}
|
value={invoice.bolt11}
|
||||||
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
||||||
statusVariant={variant} status={status}
|
statusVariant={variant} status={status}
|
||||||
/>
|
/>
|
||||||
|
@ -209,3 +201,23 @@ function ActionInfo ({ invoice }) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WalletError ({ error }) {
|
||||||
|
if (!error || error instanceof WalletConfigurationError) return null
|
||||||
|
|
||||||
|
if (!(error instanceof WalletPaymentAggregateError)) {
|
||||||
|
console.error('unexpected wallet error:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
|
||||||
|
<div className='text-info mb-2'>Paying from attached wallets failed:</div>
|
||||||
|
{error.errors.map((e, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<code>{e.wallet}: {e.reason || e.message}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { usePaidMutation } from './use-paid-mutation'
|
||||||
import { ACT_MUTATION } from '@/fragments/paidAction'
|
import { ACT_MUTATION } from '@/fragments/paidAction'
|
||||||
import { meAnonSats } from '@/lib/apollo'
|
import { meAnonSats } from '@/lib/apollo'
|
||||||
import { BoostItemInput } from './adv-post-form'
|
import { BoostItemInput } from './adv-post-form'
|
||||||
import { useWallet } from '@/wallets/index'
|
import { useSendWallets } from '@/wallets/index'
|
||||||
|
|
||||||
const defaultTips = [100, 1000, 10_000, 100_000]
|
const defaultTips = [100, 1000, 10_000, 100_000]
|
||||||
|
|
||||||
|
@ -89,7 +89,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B
|
||||||
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
|
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const wallet = useWallet()
|
const wallets = useSendWallets()
|
||||||
const [oValue, setOValue] = useState()
|
const [oValue, setOValue] = useState()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -117,7 +117,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
||||||
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeImmediately = !!wallet || me?.privates?.sats > Number(amount)
|
const closeImmediately = wallets.length > 0 || me?.privates?.sats > Number(amount)
|
||||||
if (closeImmediately) {
|
if (closeImmediately) {
|
||||||
onPaid()
|
onPaid()
|
||||||
}
|
}
|
||||||
|
@ -143,7 +143,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
||||||
})
|
})
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
addCustomTip(Number(amount))
|
addCustomTip(Number(amount))
|
||||||
}, [me, actor, !!wallet, act, item.id, onClose, abortSignal, strike])
|
}, [me, actor, wallets.length, act, item.id, onClose, abortSignal, strike])
|
||||||
|
|
||||||
return act === 'BOOST'
|
return act === 'BOOST'
|
||||||
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm>
|
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm>
|
||||||
|
@ -260,7 +260,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useZap () {
|
export function useZap () {
|
||||||
const wallet = useWallet()
|
const wallets = useSendWallets()
|
||||||
const act = useAct()
|
const act = useAct()
|
||||||
const strike = useLightning()
|
const strike = useLightning()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
|
@ -278,17 +278,18 @@ export function useZap () {
|
||||||
await abortSignal.pause({ me, amount: sats })
|
await abortSignal.pause({ me, amount: sats })
|
||||||
strike()
|
strike()
|
||||||
// batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request
|
// batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request
|
||||||
const { error } = await act({ variables, optimisticResponse, context: { batch: !!wallet || me?.privates?.sats > sats } })
|
const { error } = await act({ variables, optimisticResponse, context: { batch: wallets.length > 0 || me?.privates?.sats > sats } })
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ActCanceledError) {
|
if (error instanceof ActCanceledError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = error?.message || error?.toString?.()
|
// TODO: we should selectively toast based on error type
|
||||||
toaster.danger(reason)
|
// but right now this toast is noisy for optimistic zaps
|
||||||
|
console.error(error)
|
||||||
}
|
}
|
||||||
}, [act, toaster, strike, !!wallet])
|
}, [act, toaster, strike, wallets.length])
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActCanceledError extends Error {
|
export class ActCanceledError extends Error {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import Dropdown from 'react-bootstrap/Dropdown'
|
||||||
import Countdown from './countdown'
|
import Countdown from './countdown'
|
||||||
import { abbrNum, numWithUnits } from '@/lib/format'
|
import { abbrNum, numWithUnits } from '@/lib/format'
|
||||||
import { newComments, commentsViewedAt } from '@/lib/new-comments'
|
import { newComments, commentsViewedAt } from '@/lib/new-comments'
|
||||||
import { datePivot, timeSince } from '@/lib/time'
|
import { timeSince } from '@/lib/time'
|
||||||
import { DeleteDropdownItem } from './delete'
|
import { DeleteDropdownItem } from './delete'
|
||||||
import styles from './item.module.css'
|
import styles from './item.module.css'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
|
@ -27,6 +27,8 @@ import { useRetryCreateItem } from './use-item-submit'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
import SubPopover from './sub-popover'
|
||||||
|
import useCanEdit from './use-can-edit'
|
||||||
|
|
||||||
export default function ItemInfo ({
|
export default function ItemInfo ({
|
||||||
item, full, commentsText = 'comments',
|
item, full, commentsText = 'comments',
|
||||||
|
@ -34,12 +36,12 @@ export default function ItemInfo ({
|
||||||
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true,
|
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true,
|
||||||
setDisableRetry, disableRetry
|
setDisableRetry, disableRetry
|
||||||
}) {
|
}) {
|
||||||
const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { minutes: 10 })
|
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [hasNewComments, setHasNewComments] = useState(false)
|
const [hasNewComments, setHasNewComments] = useState(false)
|
||||||
const root = useRoot()
|
const root = useRoot()
|
||||||
const sub = item?.sub || root?.sub
|
const sub = item?.sub || root?.sub
|
||||||
|
const [canEdit, setCanEdit, editThreshold] = useCanEdit(item)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!full) {
|
if (!full) {
|
||||||
|
@ -47,19 +49,6 @@ export default function ItemInfo ({
|
||||||
}
|
}
|
||||||
}, [item])
|
}, [item])
|
||||||
|
|
||||||
// allow anon edits if they have the correct hmac for the item invoice
|
|
||||||
// (the server will verify the hmac)
|
|
||||||
const [anonEdit, setAnonEdit] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
const invParams = window.localStorage.getItem(`item:${item.id}:hash:hmac`)
|
|
||||||
setAnonEdit(!!invParams && !me && Number(item.user.id) === USER_ID.anon)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// deleted items can never be edited and every item has a 10 minute edit window
|
|
||||||
// except bios, they can always be edited but they should never show the countdown
|
|
||||||
const noEdit = !!item.deletedAt || (Date.now() >= editThreshold) || item.bio
|
|
||||||
const canEdit = !noEdit && ((me && item.mine) || anonEdit)
|
|
||||||
|
|
||||||
// territory founders can pin any post in their territory
|
// territory founders can pin any post in their territory
|
||||||
// and OPs can pin any root reply in their post
|
// and OPs can pin any root reply in their post
|
||||||
const isPost = !item.parentId
|
const isPost = !item.parentId
|
||||||
|
@ -134,9 +123,11 @@ export default function ItemInfo ({
|
||||||
</>}
|
</>}
|
||||||
</span>
|
</span>
|
||||||
{item.subName &&
|
{item.subName &&
|
||||||
<Link href={`/~${item.subName}`}>
|
<SubPopover sub={item.subName}>
|
||||||
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
|
<Link href={`/~${item.subName}`}>
|
||||||
</Link>}
|
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
|
||||||
|
</Link>
|
||||||
|
</SubPopover>}
|
||||||
{sub?.nsfw &&
|
{sub?.nsfw &&
|
||||||
<Badge className={styles.newComment} bg={null}>nsfw</Badge>}
|
<Badge className={styles.newComment} bg={null}>nsfw</Badge>}
|
||||||
{(item.outlawed && !item.mine &&
|
{(item.outlawed && !item.mine &&
|
||||||
|
@ -157,7 +148,7 @@ export default function ItemInfo ({
|
||||||
<>
|
<>
|
||||||
<EditInfo
|
<EditInfo
|
||||||
item={item} edit={edit} canEdit={canEdit}
|
item={item} edit={edit} canEdit={canEdit}
|
||||||
setCanEdit={setAnonEdit} toggleEdit={toggleEdit} editText={editText} editThreshold={editThreshold}
|
setCanEdit={setCanEdit} toggleEdit={toggleEdit} editText={editText} editThreshold={editThreshold}
|
||||||
/>
|
/>
|
||||||
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
|
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
|
||||||
<ActionDropdown>
|
<ActionDropdown>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import Badges from './badge'
|
||||||
import { MEDIA_URL } from '@/lib/constants'
|
import { MEDIA_URL } from '@/lib/constants'
|
||||||
import { abbrNum } from '@/lib/format'
|
import { abbrNum } from '@/lib/format'
|
||||||
import { Badge } from 'react-bootstrap'
|
import { Badge } from 'react-bootstrap'
|
||||||
|
import SubPopover from './sub-popover'
|
||||||
|
|
||||||
export default function ItemJob ({ item, toc, rank, children }) {
|
export default function ItemJob ({ item, toc, rank, children }) {
|
||||||
const isEmail = string().email().isValidSync(item.url)
|
const isEmail = string().email().isValidSync(item.url)
|
||||||
|
@ -62,9 +63,11 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
{item.subName &&
|
{item.subName &&
|
||||||
<Link href={`/~${item.subName}`}>
|
<SubPopover sub={item.subName}>
|
||||||
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
|
<Link href={`/~${item.subName}`}>
|
||||||
</Link>}
|
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
|
||||||
|
</Link>
|
||||||
|
</SubPopover>}
|
||||||
{item.status === 'STOPPED' &&
|
{item.status === 'STOPPED' &&
|
||||||
<>{' '}<Badge bg='info' className={styles.badge}>stopped</Badge></>}
|
<>{' '}<Badge bg='info' className={styles.badge}>stopped</Badge></>}
|
||||||
{item.mine && !item.deletedAt &&
|
{item.mine && !item.deletedAt &&
|
||||||
|
|
|
@ -283,7 +283,7 @@ function Invitification ({ n }) {
|
||||||
<>
|
<>
|
||||||
<NoteHeader color='secondary'>
|
<NoteHeader color='secondary'>
|
||||||
your invite has been redeemed by
|
your invite has been redeemed by
|
||||||
{numWithUnits(n.invite.invitees.length, {
|
{' ' + numWithUnits(n.invite.invitees.length, {
|
||||||
abbreviate: false,
|
abbreviate: false,
|
||||||
unitSingular: 'stacker',
|
unitSingular: 'stacker',
|
||||||
unitPlural: 'stackers'
|
unitPlural: 'stackers'
|
||||||
|
@ -370,6 +370,29 @@ function useActRetry ({ invoice }) {
|
||||||
invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine
|
invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine
|
||||||
? payBountyCacheMods
|
? 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
paidActionCacheMods?.update?.(cache, { data })
|
||||||
|
bountyCacheMods?.update?.(cache, { data })
|
||||||
|
}
|
||||||
|
|
||||||
return useAct({
|
return useAct({
|
||||||
query: RETRY_PAID_ACTION,
|
query: RETRY_PAID_ACTION,
|
||||||
onPayError: (e, cache, { data }) => {
|
onPayError: (e, cache, { data }) => {
|
||||||
|
@ -380,27 +403,8 @@ function useActRetry ({ invoice }) {
|
||||||
paidActionCacheMods?.onPaid?.(cache, { data })
|
paidActionCacheMods?.onPaid?.(cache, { data })
|
||||||
bountyCacheMods?.onPaid?.(cache, { data })
|
bountyCacheMods?.onPaid?.(cache, { data })
|
||||||
},
|
},
|
||||||
update: (cache, { data }) => {
|
update,
|
||||||
const response = Object.values(data)[0]
|
updateOnFallback: update
|
||||||
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 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
paidActionCacheMods?.update?.(cache, { data })
|
|
||||||
bountyCacheMods?.update?.(cache, { data })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,16 @@
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
import { useApolloClient, useMutation } from '@apollo/client'
|
||||||
import { useWallet } from '@/wallets/index'
|
import { CANCEL_INVOICE, INVOICE } from '@/fragments/wallet'
|
||||||
import { FAST_POLL_INTERVAL } from '@/lib/constants'
|
|
||||||
import { INVOICE } from '@/fragments/wallet'
|
|
||||||
import Invoice from '@/components/invoice'
|
import Invoice from '@/components/invoice'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { InvoiceCanceledError, NoAttachedWalletError, InvoiceExpiredError } from '@/wallets/errors'
|
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
|
||||||
|
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
|
||||||
|
|
||||||
export const useInvoice = () => {
|
export const useInvoice = () => {
|
||||||
const client = useApolloClient()
|
const client = useApolloClient()
|
||||||
|
const [retryPaidAction] = useMutation(RETRY_PAID_ACTION)
|
||||||
|
|
||||||
const [cancelInvoice] = useMutation(gql`
|
const [cancelInvoice] = useMutation(CANCEL_INVOICE)
|
||||||
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
|
||||||
cancelInvoice(hash: $hash, hmac: $hmac) {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
|
|
||||||
const isInvoice = useCallback(async ({ id }, that) => {
|
const isInvoice = useCallback(async ({ id }, that) => {
|
||||||
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } })
|
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } })
|
||||||
|
@ -24,15 +18,15 @@ export const useInvoice = () => {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hash, cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice
|
const { cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice
|
||||||
|
|
||||||
const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt)
|
const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt)
|
||||||
if (expired) {
|
if (expired) {
|
||||||
throw new InvoiceExpiredError(hash)
|
throw new InvoiceExpiredError(data.invoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelled || actionError) {
|
if (cancelled || actionError) {
|
||||||
throw new InvoiceCanceledError(hash, actionError)
|
throw new InvoiceCanceledError(data.invoice, actionError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// write to cache if paid
|
// write to cache if paid
|
||||||
|
@ -40,7 +34,7 @@ export const useInvoice = () => {
|
||||||
client.writeQuery({ query: INVOICE, variables: { id }, data: { invoice: data.invoice } })
|
client.writeQuery({ query: INVOICE, variables: { id }, data: { invoice: data.invoice } })
|
||||||
}
|
}
|
||||||
|
|
||||||
return that(data.invoice)
|
return { invoice: data.invoice, check: that(data.invoice) }
|
||||||
}, [client])
|
}, [client])
|
||||||
|
|
||||||
const cancel = useCallback(async ({ hash, hmac }) => {
|
const cancel = useCallback(async ({ hash, hmac }) => {
|
||||||
|
@ -49,77 +43,22 @@ export const useInvoice = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('canceling invoice:', hash)
|
console.log('canceling invoice:', hash)
|
||||||
const inv = await cancelInvoice({ variables: { hash, hmac } })
|
const { data } = await cancelInvoice({ variables: { hash, hmac } })
|
||||||
return inv
|
return data.cancelInvoice
|
||||||
}, [cancelInvoice])
|
}, [cancelInvoice])
|
||||||
|
|
||||||
return { cancel, isInvoice }
|
const retry = useCallback(async ({ id, hash, hmac }, { update }) => {
|
||||||
}
|
console.log('retrying invoice:', hash)
|
||||||
|
const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id) }, update })
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
const invoiceController = (id, isInvoice) => {
|
const newInvoice = data.retryPaidAction.invoice
|
||||||
const controller = new AbortController()
|
console.log('new invoice:', newInvoice?.hash)
|
||||||
const signal = controller.signal
|
|
||||||
controller.wait = async (waitFor = inv => inv?.actionState === 'PAID') => {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const paid = await isInvoice({ id }, waitFor)
|
|
||||||
if (paid) {
|
|
||||||
resolve()
|
|
||||||
clearInterval(interval)
|
|
||||||
signal.removeEventListener('abort', abort)
|
|
||||||
} else {
|
|
||||||
console.info(`invoice #${id}: waiting for payment ...`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
reject(err)
|
|
||||||
clearInterval(interval)
|
|
||||||
signal.removeEventListener('abort', abort)
|
|
||||||
}
|
|
||||||
}, FAST_POLL_INTERVAL)
|
|
||||||
|
|
||||||
const abort = () => {
|
return newInvoice
|
||||||
console.info(`invoice #${id}: stopped waiting`)
|
}, [retryPaidAction])
|
||||||
resolve()
|
|
||||||
clearInterval(interval)
|
|
||||||
signal.removeEventListener('abort', abort)
|
|
||||||
}
|
|
||||||
signal.addEventListener('abort', abort)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.stop = () => controller.abort()
|
return { cancel, retry, isInvoice }
|
||||||
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useWalletPayment = () => {
|
|
||||||
const invoice = useInvoice()
|
|
||||||
const wallet = useWallet()
|
|
||||||
|
|
||||||
const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
|
|
||||||
if (!wallet) {
|
|
||||||
throw new NoAttachedWalletError()
|
|
||||||
}
|
|
||||||
const controller = invoiceController(id, invoice.isInvoice)
|
|
||||||
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
|
|
||||||
wallet.sendPayment(bolt11).catch(reject)
|
|
||||||
controller.wait(waitFor)
|
|
||||||
.then(resolve)
|
|
||||||
.catch(reject)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('payment failed:', err)
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
controller.stop()
|
|
||||||
}
|
|
||||||
}, [wallet, invoice])
|
|
||||||
|
|
||||||
return waitForWalletPayment
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useQrPayment = () => {
|
export const useQrPayment = () => {
|
||||||
|
@ -138,10 +77,10 @@ export const useQrPayment = () => {
|
||||||
let paid
|
let paid
|
||||||
const cancelAndReject = async (onClose) => {
|
const cancelAndReject = async (onClose) => {
|
||||||
if (!paid && cancelOnClose) {
|
if (!paid && cancelOnClose) {
|
||||||
await invoice.cancel(inv).catch(console.error)
|
const updatedInv = await invoice.cancel(inv).catch(console.error)
|
||||||
reject(new InvoiceCanceledError(inv?.hash))
|
reject(new InvoiceCanceledError(updatedInv))
|
||||||
}
|
}
|
||||||
resolve()
|
resolve(inv)
|
||||||
}
|
}
|
||||||
showModal(onClose =>
|
showModal(onClose =>
|
||||||
<Invoice
|
<Invoice
|
||||||
|
@ -150,12 +89,11 @@ export const useQrPayment = () => {
|
||||||
description
|
description
|
||||||
status='loading'
|
status='loading'
|
||||||
successVerb='received'
|
successVerb='received'
|
||||||
useWallet={false}
|
|
||||||
walletError={walletError}
|
walletError={walletError}
|
||||||
waitFor={waitFor}
|
waitFor={waitFor}
|
||||||
onExpired={inv => reject(new InvoiceExpiredError(inv?.hash))}
|
onExpired={inv => reject(new InvoiceExpiredError(inv))}
|
||||||
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }}
|
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv, inv?.actionError)) }}
|
||||||
onPayment={() => { paid = true; onClose(); resolve() }}
|
onPayment={(inv) => { paid = true; onClose(); resolve(inv) }}
|
||||||
poll
|
poll
|
||||||
/>,
|
/>,
|
||||||
{ keepOpen, persistOnNavigate, onClose: cancelAndReject })
|
{ keepOpen, persistOnNavigate, onClose: cancelAndReject })
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import { QRCodeSVG } from 'qrcode.react'
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
import { CopyInput, InputSkeleton } from './form'
|
import { CopyInput, InputSkeleton } from './form'
|
||||||
import InvoiceStatus from './invoice-status'
|
import InvoiceStatus from './invoice-status'
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useWallet } from '@/wallets/index'
|
|
||||||
import Bolt11Info from './bolt11-info'
|
import Bolt11Info from './bolt11-info'
|
||||||
|
|
||||||
export const qrImageSettings = {
|
export const qrImageSettings = {
|
||||||
|
@ -14,22 +12,8 @@ export const qrImageSettings = {
|
||||||
excavate: true
|
excavate: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
|
export default function Qr ({ asIs, value, statusVariant, description, status }) {
|
||||||
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
||||||
const wallet = useWallet()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function effect () {
|
|
||||||
if (automated && wallet) {
|
|
||||||
try {
|
|
||||||
await wallet.sendPayment(value)
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e?.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
effect()
|
|
||||||
}, [wallet])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,10 +1,33 @@
|
||||||
import { ITEM_TYPES, ITEM_TYPES_UNIVERSAL } from '@/lib/constants'
|
import { ITEM_TYPES, ITEM_TYPES_UNIVERSAL } from '@/lib/constants'
|
||||||
|
import BootstrapForm from 'react-bootstrap/Form'
|
||||||
import { Select } from './form'
|
import { Select } from './form'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
export default function RecentHeader ({ type, sub }) {
|
function ActiveBountiesCheckbox ({ prefix }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
router.push(prefix + '/recent/bounties?' + new URLSearchParams({ active: true }).toString())
|
||||||
|
} else {
|
||||||
|
router.push(prefix + '/recent/bounties')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mx-2 mb-2'>
|
||||||
|
<BootstrapForm.Check
|
||||||
|
inline
|
||||||
|
checked={router.query.active === 'true'}
|
||||||
|
label='active only'
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecentHeader ({ type, sub }) {
|
||||||
|
const router = useRouter()
|
||||||
const prefix = sub ? `/~${sub.name}` : ''
|
const prefix = sub ? `/~${sub.name}` : ''
|
||||||
|
|
||||||
const items = sub
|
const items = sub
|
||||||
|
@ -14,18 +37,22 @@ export default function RecentHeader ({ type, sub }) {
|
||||||
: ITEM_TYPES
|
: ITEM_TYPES
|
||||||
|
|
||||||
type ||= router.query.type || type || 'posts'
|
type ||= router.query.type || type || 'posts'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='text-muted fw-bold my-1 d-flex justify-content-start align-items-center'>
|
<div className='flex-wrap'>
|
||||||
<Select
|
<div className='text-muted fw-bold my-1 d-flex justify-content-start align-items-center'>
|
||||||
groupClassName='mb-2'
|
<Select
|
||||||
className='w-auto'
|
groupClassName='mb-2'
|
||||||
name='type'
|
className='w-auto'
|
||||||
size='sm'
|
name='type'
|
||||||
value={type}
|
size='sm'
|
||||||
items={items}
|
value={type}
|
||||||
noForm
|
items={items}
|
||||||
onChange={(_, e) => router.push(prefix + (e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`))}
|
noForm
|
||||||
/>
|
onChange={(_, e) => router.push(prefix + (e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`))}
|
||||||
|
/>
|
||||||
|
{type === 'bounties' && <ActiveBountiesCheckbox prefix={prefix} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,7 +113,7 @@ export default forwardRef(function Reply ({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccessfulSubmit: (data, { resetForm }) => {
|
onSuccessfulSubmit: (data, { resetForm }) => {
|
||||||
resetForm({ text: '' })
|
resetForm({ values: { text: '' } })
|
||||||
setReply(replyOpen || false)
|
setReply(replyOpen || false)
|
||||||
},
|
},
|
||||||
navigateOnSubmit: false
|
navigateOnSubmit: false
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { SUB_FULL } from '@/fragments/subs'
|
||||||
|
import errorStyles from '@/styles/error.module.css'
|
||||||
|
import { useLazyQuery } from '@apollo/client'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import HoverablePopover from './hoverable-popover'
|
||||||
|
import { TerritoryInfo, TerritoryInfoSkeleton } from './territory-header'
|
||||||
|
import { truncateString } from '@/lib/format'
|
||||||
|
|
||||||
|
export default function SubPopover ({ sub, children }) {
|
||||||
|
const [getSub, { loading, data }] = useLazyQuery(
|
||||||
|
SUB_FULL,
|
||||||
|
{
|
||||||
|
variables: { sub },
|
||||||
|
fetchPolicy: 'cache-first'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverablePopover
|
||||||
|
onShow={getSub}
|
||||||
|
trigger={children}
|
||||||
|
body={!data || loading
|
||||||
|
? <TerritoryInfoSkeleton />
|
||||||
|
: !data.sub
|
||||||
|
? <h1 className={classNames(errorStyles.status, errorStyles.describe)}>SUB NOT FOUND</h1>
|
||||||
|
: <TerritoryInfo sub={{ ...data.sub, desc: truncateString(data.sub.desc, 280) }} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Select } from './form'
|
import { Select } from './form'
|
||||||
import { EXTRA_LONG_POLL_INTERVAL, SSR } from '@/lib/constants'
|
import { EXTRA_LONG_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||||
import { SUBS } from '@/fragments/subs'
|
import { SUBS } from '@/fragments/subs'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import styles from './sub-select.module.css'
|
import styles from './sub-select.module.css'
|
||||||
|
import { useMe } from './me'
|
||||||
|
|
||||||
export function SubSelectInitial ({ sub }) {
|
export function SubSelectInitial ({ sub }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -20,19 +21,27 @@ const DEFAULT_APPEND_SUBS = []
|
||||||
const DEFAULT_FILTER_SUBS = () => true
|
const DEFAULT_FILTER_SUBS = () => true
|
||||||
|
|
||||||
export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs = DEFAULT_FILTER_SUBS, appendSubs = DEFAULT_APPEND_SUBS }) {
|
export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs = DEFAULT_FILTER_SUBS, appendSubs = DEFAULT_APPEND_SUBS }) {
|
||||||
const { data } = useQuery(SUBS, SSR
|
const { data, refetch } = useQuery(SUBS, SSR
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
pollInterval: EXTRA_LONG_POLL_INTERVAL,
|
pollInterval: EXTRA_LONG_POLL_INTERVAL,
|
||||||
nextFetchPolicy: 'cache-and-network'
|
nextFetchPolicy: 'cache-and-network'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { me } = useMe()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch()
|
||||||
|
}, [me?.privates?.nsfwMode])
|
||||||
|
|
||||||
const [subs, setSubs] = useState([
|
const [subs, setSubs] = useState([
|
||||||
...prependSubs.filter(s => s !== sub),
|
...prependSubs.filter(s => s !== sub),
|
||||||
...(sub ? [sub] : []),
|
...(sub ? [sub] : []),
|
||||||
...appendSubs.filter(s => s !== sub)])
|
...appendSubs.filter(s => s !== sub)])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
|
|
||||||
const joined = data.subs.filter(filterSubs).filter(s => !s.meMuteSub).map(s => s.name)
|
const joined = data.subs.filter(filterSubs).filter(s => !s.meMuteSub).map(s => s.name)
|
||||||
const muted = data.subs.filter(filterSubs).filter(s => s.meMuteSub).map(s => s.name)
|
const muted = data.subs.filter(filterSubs).filter(s => s.meMuteSub).map(s => s.name)
|
||||||
const mutedSection = muted.length ? [{ label: 'muted', items: muted }] : []
|
const mutedSection = muted.length ? [{ label: 'muted', items: muted }] : []
|
||||||
|
|
|
@ -31,6 +31,17 @@ export function TerritoryDetails ({ sub, children }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TerritoryInfoSkeleton ({ children, className }) {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.item} ${styles.skeleton} ${className}`}>
|
||||||
|
<div className={styles.hunk}>
|
||||||
|
<div className={`${styles.name} clouds text-reset`} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function TerritoryInfo ({ sub }) {
|
export function TerritoryInfo ({ sub }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { useRouter } from 'next/router'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
import isEqual from 'lodash/isEqual'
|
import isEqual from 'lodash/isEqual'
|
||||||
|
import SubPopover from './sub-popover'
|
||||||
import UserPopover from './user-popover'
|
import UserPopover from './user-popover'
|
||||||
import ItemPopover from './item-popover'
|
import ItemPopover from './item-popover'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
@ -183,8 +184,12 @@ function Mention ({ children, node, href, name, id }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sub ({ children, node, href, ...props }) {
|
function Sub ({ children, node, href, name, ...props }) {
|
||||||
return <Link href={href}>{children}</Link>
|
return (
|
||||||
|
<SubPopover sub={name}>
|
||||||
|
<Link href={href}>{children}</Link>
|
||||||
|
</SubPopover>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Item ({ children, node, href, id }) {
|
function Item ({ children, node, href, id }) {
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { datePivot } from '@/lib/time'
|
||||||
|
import { useMe } from '@/components/me'
|
||||||
|
import { USER_ID } from '@/lib/constants'
|
||||||
|
|
||||||
|
export default function useCanEdit (item) {
|
||||||
|
const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { minutes: 10 })
|
||||||
|
const { me } = useMe()
|
||||||
|
|
||||||
|
// deleted items can never be edited and every item has a 10 minute edit window
|
||||||
|
// except bios, they can always be edited but they should never show the countdown
|
||||||
|
const noEdit = !!item.deletedAt || (Date.now() >= editThreshold) || item.bio
|
||||||
|
const authorEdit = me && item.mine
|
||||||
|
const [canEdit, setCanEdit] = useState(!noEdit && authorEdit)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// allow anon edits if they have the correct hmac for the item invoice
|
||||||
|
// (the server will verify the hmac)
|
||||||
|
const invParams = window.localStorage.getItem(`item:${item.id}:hash:hmac`)
|
||||||
|
const anonEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon
|
||||||
|
// anonEdit should not override canEdit, but only allow edits if they aren't already allowed
|
||||||
|
setCanEdit(canEdit => canEdit || anonEdit)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return [canEdit, setCanEdit, editThreshold]
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { useInvoice, useQrPayment, useWalletPayment } from './payment'
|
import { useInvoice, useQrPayment } from './payment'
|
||||||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
|
import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/errors'
|
||||||
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
||||||
|
import { useWalletPayment } from '@/wallets/payment'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
this is just like useMutation with a few changes:
|
this is just like useMutation with a few changes:
|
||||||
|
@ -30,25 +31,41 @@ export function usePaidMutation (mutation,
|
||||||
// innerResult is used to store/control the result of the mutation when innerMutate runs
|
// innerResult is used to store/control the result of the mutation when innerMutate runs
|
||||||
const [innerResult, setInnerResult] = useState(result)
|
const [innerResult, setInnerResult] = useState(result)
|
||||||
|
|
||||||
const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => {
|
const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor, updateOnFallback }) => {
|
||||||
let walletError
|
let walletError
|
||||||
|
let walletInvoice = invoice
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await waitForWalletPayment(invoice, waitFor)
|
return await waitForWalletPayment(walletInvoice, { waitFor, updateOnFallback })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
walletError = null
|
||||||
(!alwaysShowQROnFailure && Date.now() - start > 1000) ||
|
if (err instanceof WalletError) {
|
||||||
err instanceof InvoiceCanceledError ||
|
walletError = err
|
||||||
err instanceof InvoiceExpiredError) {
|
// get the last invoice that was attempted but failed and was canceled
|
||||||
// bail since qr code payment will also fail
|
if (err.invoice) walletInvoice = err.invoice
|
||||||
// also bail if the payment took more than 1 second
|
}
|
||||||
// and cancel the invoice if it's not already canceled so it can be retried
|
|
||||||
invoiceHelper.cancel(invoice).catch(console.error)
|
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
|
throw err
|
||||||
}
|
}
|
||||||
walletError = err
|
|
||||||
}
|
}
|
||||||
return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor })
|
|
||||||
|
const paymentAttempted = walletError instanceof WalletPaymentError
|
||||||
|
if (paymentAttempted) {
|
||||||
|
walletInvoice = await invoiceHelper.retry(walletInvoice, { update: updateOnFallback })
|
||||||
|
}
|
||||||
|
return await waitForQrPayment(walletInvoice, walletError, { persistOnNavigate, waitFor })
|
||||||
}, [waitForWalletPayment, waitForQrPayment, invoiceHelper])
|
}, [waitForWalletPayment, waitForQrPayment, invoiceHelper])
|
||||||
|
|
||||||
const innerMutate = useCallback(async ({
|
const innerMutate = useCallback(async ({
|
||||||
|
@ -60,7 +77,7 @@ export function usePaidMutation (mutation,
|
||||||
// use the most inner callbacks/options if they exist
|
// use the most inner callbacks/options if they exist
|
||||||
const {
|
const {
|
||||||
onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
|
onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
|
||||||
update, waitFor = inv => inv?.actionState === 'PAID'
|
update, waitFor = inv => inv?.actionState === 'PAID', updateOnFallback
|
||||||
} = { ...options, ...innerOptions }
|
} = { ...options, ...innerOptions }
|
||||||
const ourOnCompleted = innerOnCompleted || onCompleted
|
const ourOnCompleted = innerOnCompleted || onCompleted
|
||||||
|
|
||||||
|
@ -69,7 +86,7 @@ export function usePaidMutation (mutation,
|
||||||
throw new Error('usePaidMutation: exactly one mutation at a time is supported')
|
throw new Error('usePaidMutation: exactly one mutation at a time is supported')
|
||||||
}
|
}
|
||||||
const response = Object.values(data)[0]
|
const response = Object.values(data)[0]
|
||||||
const invoice = response?.invoice
|
let invoice = response?.invoice
|
||||||
|
|
||||||
// if the mutation returns an invoice, pay it
|
// if the mutation returns an invoice, pay it
|
||||||
if (invoice) {
|
if (invoice) {
|
||||||
|
@ -81,15 +98,28 @@ export function usePaidMutation (mutation,
|
||||||
error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined
|
error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mergeData = obj => ({
|
||||||
|
[Object.keys(data)[0]]: {
|
||||||
|
...data?.[Object.keys(data)[0]],
|
||||||
|
...obj
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// should we wait for the invoice to be paid?
|
// should we wait for the invoice to be paid?
|
||||||
if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) {
|
if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) {
|
||||||
// onCompleted is called before the invoice is paid for optimistic updates
|
// onCompleted is called before the invoice is paid for optimistic updates
|
||||||
ourOnCompleted?.(data)
|
ourOnCompleted?.(data)
|
||||||
// don't wait to pay the invoice
|
// don't wait to pay the invoice
|
||||||
waitForPayment(invoice, { persistOnNavigate, waitFor }).then(() => {
|
waitForPayment(invoice, { persistOnNavigate, waitFor, updateOnFallback }).then((invoice) => {
|
||||||
|
// invoice might have been retried during payment
|
||||||
|
data = mergeData({ invoice })
|
||||||
onPaid?.(client.cache, { data })
|
onPaid?.(client.cache, { data })
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
console.error('usePaidMutation: failed to pay invoice', e)
|
console.error('usePaidMutation: failed to pay invoice', e)
|
||||||
|
if (e.invoice) {
|
||||||
|
// update the failed invoice for the Apollo cache update
|
||||||
|
data = mergeData({ invoice: e.invoice })
|
||||||
|
}
|
||||||
// onPayError is called after the invoice fails to pay
|
// onPayError is called after the invoice fails to pay
|
||||||
// useful for updating invoiceActionState to FAILED
|
// useful for updating invoiceActionState to FAILED
|
||||||
onPayError?.(e, client.cache, { data })
|
onPayError?.(e, client.cache, { data })
|
||||||
|
@ -99,18 +129,14 @@ export function usePaidMutation (mutation,
|
||||||
// the action is pessimistic
|
// the action is pessimistic
|
||||||
try {
|
try {
|
||||||
// wait for the invoice to be paid
|
// wait for the invoice to be paid
|
||||||
await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor })
|
// returns the invoice that was paid since it might have been updated via retries
|
||||||
|
invoice = await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor, updateOnFallback })
|
||||||
if (!response.result) {
|
if (!response.result) {
|
||||||
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
|
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
|
||||||
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
|
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
|
||||||
// create new data object
|
// create new data object
|
||||||
// ( hmac is only returned on invoice creation so we need to add it back to the data )
|
// ( hmac is only returned on invoice creation so we need to add it back to the data )
|
||||||
data = {
|
data = mergeData({ ...paidAction, invoice: { ...paidAction.invoice, hmac: invoice.hmac } })
|
||||||
[Object.keys(data)[0]]: {
|
|
||||||
...paidAction,
|
|
||||||
invoice: { ...paidAction.invoice, hmac: invoice.hmac }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// we need to run update functions on mutations now that we have the data
|
// we need to run update functions on mutations now that we have the data
|
||||||
update?.(client.cache, { data })
|
update?.(client.cache, { data })
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,13 +5,11 @@ import { Button } from 'react-bootstrap'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { WALLET_LOGS } from '@/fragments/wallet'
|
import { WALLET_LOGS } from '@/fragments/wallet'
|
||||||
import { getWalletByType } from '@/wallets/common'
|
import { getWalletByType, walletTag } from '@/wallets/common'
|
||||||
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import useIndexedDB, { getDbName } from './use-indexeddb'
|
import useIndexedDB, { getDbName } from './use-indexeddb'
|
||||||
import { SSR } from '@/lib/constants'
|
import { SSR } from '@/lib/constants'
|
||||||
import { decode as bolt11Decode } from 'bolt11'
|
|
||||||
import { formatMsats } from '@/lib/format'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
export function WalletLogs ({ wallet, embedded }) {
|
export function WalletLogs ({ wallet, embedded }) {
|
||||||
|
@ -61,7 +59,7 @@ export function WalletLogs ({ wallet, embedded }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
|
function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
|
||||||
const { deleteLogs } = useWalletLogger(wallet, setLogs)
|
const { deleteLogs } = useWalletLogManager(setLogs)
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
|
|
||||||
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
||||||
|
@ -110,11 +108,11 @@ function useWalletLogDB () {
|
||||||
return { add, getPage, clear, error, notSupported }
|
return { add, getPage, clear, error, notSupported }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWalletLogger (wallet, setLogs) {
|
export function useWalletLogManager (setLogs) {
|
||||||
const { add, clear, notSupported } = useWalletLogDB()
|
const { add, clear, notSupported } = useWalletLogDB()
|
||||||
|
|
||||||
const appendLog = useCallback(async (wallet, level, message, context) => {
|
const appendLog = useCallback(async (wallet, level, message, context) => {
|
||||||
const log = { wallet: tag(wallet), level, message, ts: +new Date(), context }
|
const log = { wallet: walletTag(wallet.def), level, message, ts: +new Date(), context }
|
||||||
try {
|
try {
|
||||||
if (notSupported) {
|
if (notSupported) {
|
||||||
console.log('cannot persist wallet log: indexeddb not supported')
|
console.log('cannot persist wallet log: indexeddb not supported')
|
||||||
|
@ -146,56 +144,20 @@ export function useWalletLogger (wallet, setLogs) {
|
||||||
}
|
}
|
||||||
if (!wallet || wallet.sendPayment) {
|
if (!wallet || wallet.sendPayment) {
|
||||||
try {
|
try {
|
||||||
const walletTag = wallet ? tag(wallet) : null
|
const tag = wallet ? walletTag(wallet.def) : null
|
||||||
if (notSupported) {
|
if (notSupported) {
|
||||||
console.log('cannot clear wallet logs: indexeddb not supported')
|
console.log('cannot clear wallet logs: indexeddb not supported')
|
||||||
} else {
|
} else {
|
||||||
await clear('wallet_ts', walletTag ? window.IDBKeyRange.bound([walletTag, 0], [walletTag, Infinity]) : null)
|
await clear('wallet_ts', tag ? window.IDBKeyRange.bound([tag, 0], [tag, Infinity]) : null)
|
||||||
}
|
}
|
||||||
setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false))
|
setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag : false))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('failed to delete logs', e)
|
console.error('failed to delete logs', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [clear, deleteServerWalletLogs, setLogs, notSupported])
|
}, [clear, deleteServerWalletLogs, setLogs, notSupported])
|
||||||
|
|
||||||
const log = useCallback(level => (message, context = {}) => {
|
return { appendLog, deleteLogs }
|
||||||
if (!wallet) {
|
|
||||||
// console.error('cannot log: no wallet set')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context?.bolt11) {
|
|
||||||
// automatically populate context from bolt11 to avoid duplicating this code
|
|
||||||
const decoded = bolt11Decode(context.bolt11)
|
|
||||||
context = {
|
|
||||||
...context,
|
|
||||||
amount: formatMsats(decoded.millisatoshis),
|
|
||||||
payment_hash: decoded.tagsObject.payment_hash,
|
|
||||||
description: decoded.tagsObject.description,
|
|
||||||
created_at: new Date(decoded.timestamp * 1000).toISOString(),
|
|
||||||
expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(),
|
|
||||||
// payments should affect wallet status
|
|
||||||
status: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.send = true
|
|
||||||
|
|
||||||
appendLog(wallet, level, message, context)
|
|
||||||
console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message)
|
|
||||||
}, [appendLog, wallet])
|
|
||||||
|
|
||||||
const logger = useMemo(() => ({
|
|
||||||
ok: (message, context) => log('ok')(message, context),
|
|
||||||
info: (message, context) => log('info')(message, context),
|
|
||||||
error: (message, context) => log('error')(message, context)
|
|
||||||
}), [log])
|
|
||||||
|
|
||||||
return { logger, deleteLogs }
|
|
||||||
}
|
|
||||||
|
|
||||||
function tag (walletDef) {
|
|
||||||
return walletDef.shortName || walletDef.name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
||||||
|
@ -227,7 +189,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
||||||
console.log('cannot get client wallet logs: indexeddb not supported')
|
console.log('cannot get client wallet logs: indexeddb not supported')
|
||||||
} else {
|
} else {
|
||||||
const indexName = walletDef ? 'wallet_ts' : 'ts'
|
const indexName = walletDef ? 'wallet_ts' : 'ts'
|
||||||
const query = walletDef ? window.IDBKeyRange.bound([tag(walletDef), -Infinity], [tag(walletDef), Infinity]) : null
|
const query = walletDef ? window.IDBKeyRange.bound([walletTag(walletDef), -Infinity], [walletTag(walletDef), Infinity]) : null
|
||||||
|
|
||||||
result = await getPage(page, pageSize, indexName, query, 'prev')
|
result = await getPage(page, pageSize, indexName, query, 'prev')
|
||||||
// if given wallet has no walletType it means logs are only stored in local IDB
|
// if given wallet has no walletType it means logs are only stored in local IDB
|
||||||
|
@ -272,7 +234,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
||||||
|
|
||||||
const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({
|
const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({
|
||||||
ts: +new Date(createdAt),
|
ts: +new Date(createdAt),
|
||||||
wallet: tag(getWalletByType(walletType)),
|
wallet: walletTag(getWalletByType(walletType)),
|
||||||
...log
|
...log
|
||||||
}))
|
}))
|
||||||
const combinedLogs = uniqueSort([...result.data, ...newLogs])
|
const combinedLogs = uniqueSort([...result.data, ...newLogs])
|
||||||
|
|
|
@ -19,5 +19,6 @@ export const INVITE_FIELDS = gql`
|
||||||
...StreakFields
|
...StreakFields
|
||||||
}
|
}
|
||||||
poor
|
poor
|
||||||
|
description
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
|
@ -37,6 +37,7 @@ ${STREAK_FIELDS}
|
||||||
imgproxyOnly
|
imgproxyOnly
|
||||||
showImagesAndVideos
|
showImagesAndVideos
|
||||||
nostrCrossposting
|
nostrCrossposting
|
||||||
|
nsfwMode
|
||||||
sats
|
sats
|
||||||
tipDefault
|
tipDefault
|
||||||
tipRandom
|
tipRandom
|
||||||
|
|
|
@ -221,3 +221,12 @@ export const SET_WALLET_PRIORITY = gql`
|
||||||
setWalletPriority(id: $id, priority: $priority)
|
setWalletPriority(id: $id, priority: $priority)
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const CANCEL_INVOICE = gql`
|
||||||
|
${INVOICE_FIELDS}
|
||||||
|
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
||||||
|
cancelInvoice(hash: $hash, hmac: $hmac) {
|
||||||
|
...InvoiceFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
|
@ -27,6 +27,7 @@ export const UPLOAD_TYPES_ALLOW = [
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/webp',
|
'image/webp',
|
||||||
|
'video/quicktime',
|
||||||
'video/mp4',
|
'video/mp4',
|
||||||
'video/mpeg',
|
'video/mpeg',
|
||||||
'video/webm'
|
'video/webm'
|
||||||
|
|
|
@ -204,3 +204,49 @@ export const toPositive = (x) => {
|
||||||
if (typeof x === 'bigint') return toPositiveBigInt(x)
|
if (typeof x === 'bigint') return toPositiveBigInt(x)
|
||||||
return toPositiveNumber(x)
|
return toPositiveNumber(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncates a string intelligently, trying to keep natural breaks
|
||||||
|
* @param {string} str - The string to truncate
|
||||||
|
* @param {number} maxLength - Maximum length of the result
|
||||||
|
* @param {string} [suffix='...'] - String to append when truncated
|
||||||
|
* @returns {string} Truncated string
|
||||||
|
*/
|
||||||
|
export const truncateString = (str, maxLength, suffix = ' ...') => {
|
||||||
|
if (!str || str.length <= maxLength) return str
|
||||||
|
|
||||||
|
const effectiveLength = maxLength - suffix.length
|
||||||
|
|
||||||
|
// Split into paragraphs and accumulate until we exceed the limit
|
||||||
|
const paragraphs = str.split(/\n\n+/)
|
||||||
|
let result = ''
|
||||||
|
for (const paragraph of paragraphs) {
|
||||||
|
if ((result + paragraph).length > effectiveLength) {
|
||||||
|
// If this is the first paragraph and it's too long,
|
||||||
|
// fall back to sentence/word breaking
|
||||||
|
if (!result) {
|
||||||
|
// Try to break at sentence
|
||||||
|
const sentenceBreak = paragraph.slice(0, effectiveLength).match(/[.!?]\s+[A-Z]/g)
|
||||||
|
if (sentenceBreak) {
|
||||||
|
const lastBreak = paragraph.lastIndexOf(sentenceBreak[sentenceBreak.length - 1], effectiveLength)
|
||||||
|
if (lastBreak > effectiveLength / 2) {
|
||||||
|
return paragraph.slice(0, lastBreak + 1) + suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to break at word
|
||||||
|
const wordBreak = paragraph.lastIndexOf(' ', effectiveLength)
|
||||||
|
if (wordBreak > 0) {
|
||||||
|
return paragraph.slice(0, wordBreak) + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to character break
|
||||||
|
return paragraph.slice(0, effectiveLength) + suffix
|
||||||
|
}
|
||||||
|
return result.trim() + suffix
|
||||||
|
}
|
||||||
|
result += (result ? '\n\n' : '') + paragraph
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
|
@ -478,7 +478,9 @@ export const bioSchema = object({
|
||||||
|
|
||||||
export const inviteSchema = object({
|
export const inviteSchema = object({
|
||||||
gift: intValidator.positive('must be greater than 0').required('required'),
|
gift: intValidator.positive('must be greater than 0').required('required'),
|
||||||
limit: intValidator.positive('must be positive')
|
limit: intValidator.positive('must be positive'),
|
||||||
|
description: string().trim().max(40, 'must be at most 40 characters'),
|
||||||
|
id: string().matches(/^[\w-_]+$/, 'only letters, numbers, underscores, and hyphens').min(4, 'must be at least 4 characters').max(32, 'must be at most 32 characters')
|
||||||
})
|
})
|
||||||
|
|
||||||
export const pushSubscriptionSchema = object({
|
export const pushSubscriptionSchema = object({
|
||||||
|
|
|
@ -340,10 +340,10 @@ export async function notifyEarner (userId, earnings) {
|
||||||
export async function notifyDeposit (userId, invoice) {
|
export async function notifyDeposit (userId, invoice) {
|
||||||
try {
|
try {
|
||||||
await sendUserNotification(userId, {
|
await sendUserNotification(userId, {
|
||||||
title: `${numWithUnits(msatsToSats(invoice.received_mtokens), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`,
|
title: `${numWithUnits(msatsToSats(invoice.msatsReceived), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`,
|
||||||
body: invoice.comment || undefined,
|
body: invoice.comment || undefined,
|
||||||
tag: 'DEPOSIT',
|
tag: 'DEPOSIT',
|
||||||
data: { sats: msatsToSats(invoice.received_mtokens) }
|
data: { sats: msatsToSats(invoice.msatsReceived) }
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { PriceProvider } from '@/components/price'
|
||||||
import { BlockHeightProvider } from '@/components/block-height'
|
import { BlockHeightProvider } from '@/components/block-height'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import { useRouter } from 'next/dist/client/router'
|
import { useRouter } from 'next/dist/client/router'
|
||||||
import { useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { ShowModalProvider } from '@/components/modal'
|
import { ShowModalProvider } from '@/components/modal'
|
||||||
import ErrorBoundary from '@/components/error-boundary'
|
import ErrorBoundary from '@/components/error-boundary'
|
||||||
import { LightningProvider } from '@/components/lightning'
|
import { LightningProvider } from '@/components/lightning'
|
||||||
|
@ -46,9 +46,17 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
||||||
const client = getApolloClient()
|
const client = getApolloClient()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const shouldShowProgressBar = useCallback((newPathname, shallow) => {
|
||||||
|
return !shallow || newPathname !== router.pathname
|
||||||
|
}, [router.pathname])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nprogressStart = (_, { shallow }) => !shallow && NProgress.start()
|
const nprogressStart = (newPathname, { shallow }) => {
|
||||||
const nprogressDone = (_, { shallow }) => !shallow && NProgress.done()
|
shouldShowProgressBar(newPathname, shallow) && NProgress.start()
|
||||||
|
}
|
||||||
|
const nprogressDone = (newPathname, { shallow }) => {
|
||||||
|
NProgress.done()
|
||||||
|
}
|
||||||
|
|
||||||
router.events.on('routeChangeStart', nprogressStart)
|
router.events.on('routeChangeStart', nprogressStart)
|
||||||
router.events.on('routeChangeComplete', nprogressDone)
|
router.events.on('routeChangeComplete', nprogressDone)
|
||||||
|
@ -77,7 +85,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
||||||
router.events.off('routeChangeComplete', nprogressDone)
|
router.events.off('routeChangeComplete', nprogressDone)
|
||||||
router.events.off('routeChangeError', nprogressDone)
|
router.events.off('routeChangeError', nprogressDone)
|
||||||
}
|
}
|
||||||
}, [router.asPath, props?.apollo])
|
}, [router.asPath, props?.apollo, shouldShowProgressBar])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// hack to disable ios pwa prompt for https://github.com/stackernews/stacker.news/issues/953
|
// hack to disable ios pwa prompt for https://github.com/stackernews/stacker.news/issues/953
|
||||||
|
|
|
@ -9,6 +9,8 @@ import Invite from '@/components/invite'
|
||||||
import { inviteSchema } from '@/lib/validate'
|
import { inviteSchema } from '@/lib/validate'
|
||||||
import { SSR } from '@/lib/constants'
|
import { SSR } from '@/lib/constants'
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
|
import Info from '@/components/info'
|
||||||
|
import Text from '@/components/text'
|
||||||
|
|
||||||
// force SSR to include CSP nonces
|
// force SSR to include CSP nonces
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: null })
|
export const getServerSideProps = getGetServerSideProps({ query: null })
|
||||||
|
@ -17,8 +19,8 @@ function InviteForm () {
|
||||||
const [createInvite] = useMutation(
|
const [createInvite] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
${INVITE_FIELDS}
|
${INVITE_FIELDS}
|
||||||
mutation createInvite($gift: Int!, $limit: Int) {
|
mutation createInvite($id: String, $gift: Int!, $limit: Int, $description: String) {
|
||||||
createInvite(gift: $gift, limit: $limit) {
|
createInvite(id: $id, gift: $gift, limit: $limit, description: $description) {
|
||||||
...InviteFields
|
...InviteFields
|
||||||
}
|
}
|
||||||
}`, {
|
}`, {
|
||||||
|
@ -39,20 +41,28 @@ function InviteForm () {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
id: '',
|
||||||
|
gift: 1000,
|
||||||
|
limit: 1,
|
||||||
|
description: ''
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={initialValues}
|
||||||
gift: 100,
|
|
||||||
limit: 1
|
|
||||||
}}
|
|
||||||
schema={inviteSchema}
|
schema={inviteSchema}
|
||||||
onSubmit={async ({ limit, gift }) => {
|
onSubmit={async ({ id, gift, limit, description }, { resetForm }) => {
|
||||||
const { error } = await createInvite({
|
const { error } = await createInvite({
|
||||||
variables: {
|
variables: {
|
||||||
gift: Number(gift), limit: limit ? Number(limit) : limit
|
id: id || undefined,
|
||||||
|
gift: Number(gift),
|
||||||
|
limit: limit ? Number(limit) : limit,
|
||||||
|
description: description || undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
resetForm({ values: initialValues })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
|
@ -65,8 +75,40 @@ function InviteForm () {
|
||||||
label={<>invitee limit <small className='text-muted ms-2'>optional</small></>}
|
label={<>invitee limit <small className='text-muted ms-2'>optional</small></>}
|
||||||
name='limit'
|
name='limit'
|
||||||
/>
|
/>
|
||||||
|
<AccordianItem
|
||||||
<SubmitButton variant='secondary'>create</SubmitButton>
|
headerColor='#6c757d' header='advanced' body={
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
prepend={<InputGroup.Text className='text-muted'>{`${process.env.NEXT_PUBLIC_URL}/invites/`}</InputGroup.Text>}
|
||||||
|
label={<>invite code <small className='text-muted ms-2'>optional</small></>}
|
||||||
|
name='id'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<div className='d-flex align-items-center'>
|
||||||
|
description <small className='text-muted ms-2'>optional</small>
|
||||||
|
<Info>
|
||||||
|
<Text>
|
||||||
|
A brief description to keep track of the invite purpose, such as "Shared in group chat".
|
||||||
|
This description is private and visible only to you.
|
||||||
|
</Text>
|
||||||
|
</Info>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
name='description'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
className='mt-4'
|
||||||
|
variant='secondary'
|
||||||
|
>create
|
||||||
|
</SubmitButton>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ export default function FullInvoice () {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenterLayout>
|
<CenterLayout>
|
||||||
<Invoice id={router.query.id} query={INVOICE_FULL} poll description status='loading' bolt11Info useWallet={false} />
|
<Invoice id={router.query.id} query={INVOICE_FULL} poll description status='loading' bolt11Info />
|
||||||
</CenterLayout>
|
</CenterLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { useRouter } from 'next/router'
|
||||||
import PageLoading from '@/components/page-loading'
|
import PageLoading from '@/components/page-loading'
|
||||||
import { FeeButtonProvider } from '@/components/fee-button'
|
import { FeeButtonProvider } from '@/components/fee-button'
|
||||||
import SubSelect from '@/components/sub-select'
|
import SubSelect from '@/components/sub-select'
|
||||||
|
import useCanEdit from '@/components/use-can-edit'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({
|
export const getServerSideProps = getGetServerSideProps({
|
||||||
query: ITEM,
|
query: ITEM,
|
||||||
|
@ -26,7 +27,7 @@ export default function PostEdit ({ ssrData }) {
|
||||||
const { item } = data || ssrData
|
const { item } = data || ssrData
|
||||||
const [sub, setSub] = useState(item.subName)
|
const [sub, setSub] = useState(item.subName)
|
||||||
|
|
||||||
const editThreshold = new Date(item?.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000
|
const [,, editThreshold] = useCanEdit(item)
|
||||||
|
|
||||||
let FormType = DiscussionForm
|
let FormType = DiscussionForm
|
||||||
let itemType = 'DISCUSSION'
|
let itemType = 'DISCUSSION'
|
||||||
|
|
|
@ -9,8 +9,21 @@ import { useQuery } from '@apollo/client'
|
||||||
import PageLoading from '@/components/page-loading'
|
import PageLoading from '@/components/page-loading'
|
||||||
|
|
||||||
const staticVariables = { sort: 'recent' }
|
const staticVariables = { sort: 'recent' }
|
||||||
const variablesFunc = vars =>
|
|
||||||
({ includeComments: COMMENT_TYPE_QUERY.includes(vars.type), ...staticVariables, ...vars })
|
function variablesFunc (vars) {
|
||||||
|
let type = vars?.type || ''
|
||||||
|
|
||||||
|
if (type === 'bounties' && vars?.active) {
|
||||||
|
type = 'bounties_active'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ({
|
||||||
|
includeComments: COMMENT_TYPE_QUERY.includes(vars.type),
|
||||||
|
...staticVariables,
|
||||||
|
...vars,
|
||||||
|
type
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({
|
export const getServerSideProps = getGetServerSideProps({
|
||||||
query: SUB_ITEMS,
|
query: SUB_ITEMS,
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Invite" ADD COLUMN "description" TEXT;
|
|
@ -477,6 +477,8 @@ model Invite {
|
||||||
user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
invitees User[]
|
invitees User[]
|
||||||
|
|
||||||
|
description String?
|
||||||
|
|
||||||
@@index([createdAt], map: "Invite.created_at_index")
|
@@index([createdAt], map: "Invite.created_at_index")
|
||||||
@@index([userId], map: "Invite.userId_index")
|
@@index([userId], map: "Invite.userId_index")
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,10 @@ export function getStorageKey (name, userId) {
|
||||||
return storageKey
|
return storageKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function walletTag (walletDef) {
|
||||||
|
return walletDef.shortName || walletDef.name
|
||||||
|
}
|
||||||
|
|
||||||
export function walletPrioritySort (w1, w2) {
|
export function walletPrioritySort (w1, w2) {
|
||||||
// enabled/configured wallets always come before disabled/unconfigured wallets
|
// enabled/configured wallets always come before disabled/unconfigured wallets
|
||||||
if ((w1.config?.enabled && !w2.config?.enabled) || (isConfigured(w1) && !isConfigured(w2))) {
|
if ((w1.config?.enabled && !w2.config?.enabled) || (isConfigured(w1) && !isConfigured(w2))) {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { canReceive, canSend, getStorageKey, saveWalletLocally, siftConfig, upse
|
||||||
import { useMutation } from '@apollo/client'
|
import { useMutation } from '@apollo/client'
|
||||||
import { generateMutation } from './graphql'
|
import { generateMutation } from './graphql'
|
||||||
import { REMOVE_WALLET } from '@/fragments/wallet'
|
import { REMOVE_WALLET } from '@/fragments/wallet'
|
||||||
import { useWalletLogger } from '@/components/wallet-logger'
|
import { useWalletLogger } from '@/wallets/logger'
|
||||||
import { useWallets } from '.'
|
import { useWallets } from '.'
|
||||||
import validateWallet from './validate'
|
import validateWallet from './validate'
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ export function useWalletConfigurator (wallet) {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const { reloadLocalWallets } = useWallets()
|
const { reloadLocalWallets } = useWallets()
|
||||||
const { encrypt, isActive } = useVault()
|
const { encrypt, isActive } = useVault()
|
||||||
const { logger } = useWalletLogger(wallet?.def)
|
const logger = useWalletLogger(wallet)
|
||||||
const [upsertWallet] = useMutation(generateMutation(wallet?.def))
|
const [upsertWallet] = useMutation(generateMutation(wallet?.def))
|
||||||
const [removeWallet] = useMutation(REMOVE_WALLET)
|
const [removeWallet] = useMutation(REMOVE_WALLET)
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ export function useWalletConfigurator (wallet) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return { clientConfig, serverConfig }
|
return { clientConfig, serverConfig }
|
||||||
}, [wallet])
|
}, [wallet, logger])
|
||||||
|
|
||||||
const _detachFromServer = useCallback(async () => {
|
const _detachFromServer = useCallback(async () => {
|
||||||
await removeWallet({ variables: { id: wallet.config.id } })
|
await removeWallet({ variables: { id: wallet.config.id } })
|
||||||
|
|
|
@ -1,22 +1,87 @@
|
||||||
export class InvoiceCanceledError extends Error {
|
export class InvoiceCanceledError extends Error {
|
||||||
constructor (hash, actionError) {
|
constructor (invoice, actionError) {
|
||||||
super(actionError ?? `invoice canceled: ${hash}`)
|
super(actionError ?? `invoice canceled: ${invoice.hash}`)
|
||||||
this.name = 'InvoiceCanceledError'
|
this.name = 'InvoiceCanceledError'
|
||||||
this.hash = hash
|
this.invoice = invoice
|
||||||
this.actionError = actionError
|
this.actionError = actionError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NoAttachedWalletError extends Error {
|
export class InvoiceExpiredError extends Error {
|
||||||
constructor () {
|
constructor (invoice) {
|
||||||
super('no attached wallet found')
|
super(`invoice expired: ${invoice.hash}`)
|
||||||
this.name = 'NoAttachedWalletError'
|
this.name = 'InvoiceExpiredError'
|
||||||
|
this.invoice = invoice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvoiceExpiredError extends Error {
|
export class WalletError extends Error {}
|
||||||
constructor (hash) {
|
export class WalletPaymentError extends WalletError {}
|
||||||
super(`invoice expired: ${hash}`)
|
export class WalletConfigurationError extends WalletError {}
|
||||||
this.name = 'InvoiceExpiredError'
|
|
||||||
|
export class WalletNotEnabledError extends WalletConfigurationError {
|
||||||
|
constructor (name) {
|
||||||
|
super(`wallet is not enabled: ${name}`)
|
||||||
|
this.name = 'WalletNotEnabledError'
|
||||||
|
this.wallet = name
|
||||||
|
this.reason = 'wallet is not enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletSendNotConfiguredError extends WalletConfigurationError {
|
||||||
|
constructor (name) {
|
||||||
|
super(`wallet send is not configured: ${name}`)
|
||||||
|
this.name = 'WalletSendNotConfiguredError'
|
||||||
|
this.wallet = name
|
||||||
|
this.reason = 'wallet send is not configured'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletSenderError extends WalletPaymentError {
|
||||||
|
constructor (name, invoice, message) {
|
||||||
|
super(`${name} failed to pay invoice ${invoice.hash}: ${message}`)
|
||||||
|
this.name = 'WalletSenderError'
|
||||||
|
this.wallet = name
|
||||||
|
this.invoice = invoice
|
||||||
|
this.reason = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletsNotAvailableError extends WalletConfigurationError {
|
||||||
|
constructor () {
|
||||||
|
super('no wallet available')
|
||||||
|
this.name = 'WalletsNotAvailableError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletAggregateError extends WalletError {
|
||||||
|
constructor (errors, invoice) {
|
||||||
|
super('WalletAggregateError')
|
||||||
|
this.name = 'WalletAggregateError'
|
||||||
|
this.errors = errors.reduce((acc, e) => {
|
||||||
|
if (Array.isArray(e?.errors)) {
|
||||||
|
acc.push(...e.errors)
|
||||||
|
} else {
|
||||||
|
acc.push(e)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
this.invoice = invoice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletPaymentAggregateError extends WalletPaymentError {
|
||||||
|
constructor (errors, invoice) {
|
||||||
|
super('WalletPaymentAggregateError')
|
||||||
|
this.name = 'WalletPaymentAggregateError'
|
||||||
|
this.errors = errors.reduce((acc, e) => {
|
||||||
|
if (Array.isArray(e?.errors)) {
|
||||||
|
acc.push(...e.errors)
|
||||||
|
} else {
|
||||||
|
acc.push(e)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, []).filter(e => e instanceof WalletPaymentError)
|
||||||
|
this.invoice = invoice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,11 +5,8 @@ import { useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
|
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
|
||||||
import useVault from '@/components/vault/use-vault'
|
import useVault from '@/components/vault/use-vault'
|
||||||
import { useWalletLogger } from '@/components/wallet-logger'
|
|
||||||
import { decode as bolt11Decode } from 'bolt11'
|
|
||||||
import walletDefs from '@/wallets/client'
|
import walletDefs from '@/wallets/client'
|
||||||
import { generateMutation } from './graphql'
|
import { generateMutation } from './graphql'
|
||||||
import { formatSats } from '@/lib/format'
|
|
||||||
|
|
||||||
const WalletsContext = createContext({
|
const WalletsContext = createContext({
|
||||||
wallets: []
|
wallets: []
|
||||||
|
@ -128,7 +125,7 @@ export function WalletsProvider ({ children }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort by priority, then add status field
|
// sort by priority
|
||||||
return Object.values(merged).sort(walletPrioritySort)
|
return Object.values(merged).sort(walletPrioritySort)
|
||||||
}, [serverWallets, localWallets])
|
}, [serverWallets, localWallets])
|
||||||
|
|
||||||
|
@ -218,34 +215,13 @@ export function useWallets () {
|
||||||
|
|
||||||
export function useWallet (name) {
|
export function useWallet (name) {
|
||||||
const { wallets } = useWallets()
|
const { wallets } = useWallets()
|
||||||
|
return wallets.find(w => w.def.name === name)
|
||||||
const wallet = useMemo(() => {
|
}
|
||||||
if (name) {
|
|
||||||
return wallets.find(w => w.def.name === name)
|
export function useSendWallets () {
|
||||||
}
|
const { wallets } = useWallets()
|
||||||
|
// return the first enabled wallet that is available and can send
|
||||||
// return the first enabled wallet that is available and can send
|
return wallets
|
||||||
return wallets
|
.filter(w => !w.def.isAvailable || w.def.isAvailable())
|
||||||
.filter(w => !w.def.isAvailable || w.def.isAvailable())
|
.filter(w => w.config?.enabled && canSend(w))
|
||||||
.filter(w => w.config?.enabled && canSend(w))[0]
|
|
||||||
}, [wallets, name])
|
|
||||||
|
|
||||||
const { logger } = useWalletLogger(wallet?.def)
|
|
||||||
|
|
||||||
const sendPayment = useCallback(async (bolt11) => {
|
|
||||||
const decoded = bolt11Decode(bolt11)
|
|
||||||
logger.info(`↗ sending payment: ${formatSats(decoded.satoshis)}`, { bolt11 })
|
|
||||||
try {
|
|
||||||
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
|
|
||||||
logger.ok(`↗ payment sent: ${formatSats(decoded.satoshis)}`, { bolt11, preimage })
|
|
||||||
} catch (err) {
|
|
||||||
const message = err.message || err.toString?.()
|
|
||||||
logger.error(`payment failed: ${message}`, { bolt11 })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}, [wallet, logger])
|
|
||||||
|
|
||||||
if (!wallet) return null
|
|
||||||
|
|
||||||
return { ...wallet, sendPayment }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
|
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
|
||||||
import { Mutex } from 'async-mutex'
|
import { Mutex } from 'async-mutex'
|
||||||
export * from '@/wallets/lnc'
|
export * from '@/wallets/lnc'
|
||||||
|
|
||||||
|
@ -15,20 +13,12 @@ export async function testSendPayment (credentials, { logger }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendPayment (bolt11, credentials, { logger }) {
|
export async function sendPayment (bolt11, credentials, { logger }) {
|
||||||
const hash = bolt11Tags(bolt11).payment_hash
|
|
||||||
return await mutex.runExclusive(async () => {
|
return await mutex.runExclusive(async () => {
|
||||||
try {
|
const lnc = await getLNC(credentials, { logger })
|
||||||
const lnc = await getLNC(credentials, { logger })
|
const { paymentError, paymentPreimage: preimage } = await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
||||||
const { paymentError, paymentPreimage: preimage } = await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
if (paymentError) throw new Error(paymentError)
|
||||||
if (paymentError) throw new Error(paymentError)
|
if (!preimage) throw new Error('No preimage in response')
|
||||||
if (!preimage) throw new Error('No preimage in response')
|
return preimage
|
||||||
return preimage
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err.message || err.toString?.()
|
|
||||||
if (msg.includes('invoice expired')) throw new InvoiceExpiredError(hash)
|
|
||||||
if (msg.includes('canceled')) throw new InvoiceCanceledError(hash)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { decode as bolt11Decode } from 'bolt11'
|
||||||
|
import { formatMsats } from '@/lib/format'
|
||||||
|
import { walletTag } from '@/wallets/common'
|
||||||
|
import { useWalletLogManager } from '@/components/wallet-logger'
|
||||||
|
|
||||||
|
export function useWalletLoggerFactory () {
|
||||||
|
const { appendLog } = useWalletLogManager()
|
||||||
|
|
||||||
|
const log = useCallback((wallet, level) => (message, context = {}) => {
|
||||||
|
if (!wallet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context?.bolt11) {
|
||||||
|
// automatically populate context from bolt11 to avoid duplicating this code
|
||||||
|
const decoded = bolt11Decode(context.bolt11)
|
||||||
|
context = {
|
||||||
|
...context,
|
||||||
|
amount: formatMsats(decoded.millisatoshis),
|
||||||
|
payment_hash: decoded.tagsObject.payment_hash,
|
||||||
|
description: decoded.tagsObject.description,
|
||||||
|
created_at: new Date(decoded.timestamp * 1000).toISOString(),
|
||||||
|
expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(),
|
||||||
|
// payments should affect wallet status
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.send = true
|
||||||
|
|
||||||
|
appendLog(wallet, level, message, context)
|
||||||
|
console[level !== 'error' ? 'info' : 'error'](`[${walletTag(wallet.def)}]`, message)
|
||||||
|
}, [appendLog])
|
||||||
|
|
||||||
|
return useCallback(wallet => ({
|
||||||
|
ok: (message, context) => log(wallet, 'ok')(message, context),
|
||||||
|
info: (message, context) => log(wallet, 'info')(message, context),
|
||||||
|
error: (message, context) => log(wallet, 'error')(message, context)
|
||||||
|
}), [log])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWalletLogger (wallet) {
|
||||||
|
const factory = useWalletLoggerFactory()
|
||||||
|
return factory(wallet)
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useSendWallets } from '@/wallets'
|
||||||
|
import { formatSats } from '@/lib/format'
|
||||||
|
import { useInvoice } from '@/components/payment'
|
||||||
|
import { FAST_POLL_INTERVAL } from '@/lib/constants'
|
||||||
|
import {
|
||||||
|
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
|
||||||
|
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError
|
||||||
|
} from '@/wallets/errors'
|
||||||
|
import { canSend } from './common'
|
||||||
|
import { useWalletLoggerFactory } from './logger'
|
||||||
|
|
||||||
|
export function useWalletPayment () {
|
||||||
|
const wallets = useSendWallets()
|
||||||
|
const sendPayment = useSendPayment()
|
||||||
|
const invoiceHelper = useInvoice()
|
||||||
|
|
||||||
|
return useCallback(async (invoice, { waitFor, updateOnFallback }) => {
|
||||||
|
let aggregateError = new WalletAggregateError([])
|
||||||
|
let latestInvoice = invoice
|
||||||
|
|
||||||
|
// throw a special error that caller can handle separately if no payment was attempted
|
||||||
|
if (wallets.length === 0) {
|
||||||
|
throw new WalletsNotAvailableError()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [i, wallet] of wallets.entries()) {
|
||||||
|
const controller = invoiceController(latestInvoice, invoiceHelper.isInvoice)
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
// can't await wallet payments since we might pay hold invoices and thus payments might not settle immediately.
|
||||||
|
// that's why we separately check if we received the payment with the invoice controller.
|
||||||
|
sendPayment(wallet, latestInvoice).catch(reject)
|
||||||
|
controller.wait(waitFor)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// cancel invoice to make sure it cannot be paid later and create new invoice to retry.
|
||||||
|
// we only need to do this if payment was attempted which is not the case if the wallet is not enabled.
|
||||||
|
if (err instanceof WalletPaymentError) {
|
||||||
|
await invoiceHelper.cancel(latestInvoice)
|
||||||
|
|
||||||
|
// is there another wallet to try?
|
||||||
|
const lastAttempt = i === wallets.length - 1
|
||||||
|
if (!lastAttempt) {
|
||||||
|
latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: receiver fallbacks
|
||||||
|
//
|
||||||
|
// if payment failed because of the receiver, we should use the same wallet again.
|
||||||
|
// if (err instanceof ReceiverError) { ... }
|
||||||
|
|
||||||
|
// try next wallet if the payment failed because of the wallet
|
||||||
|
// and not because it expired or was canceled
|
||||||
|
if (err instanceof WalletError) {
|
||||||
|
aggregateError = new WalletAggregateError([aggregateError, err], latestInvoice)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// payment failed not because of the sender or receiver wallet. bail out of attemping wallets.
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
controller.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we reach this line, no wallet payment succeeded
|
||||||
|
throw new WalletPaymentAggregateError([aggregateError], latestInvoice)
|
||||||
|
}, [wallets, invoiceHelper, sendPayment])
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoiceController (inv, isInvoice) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const signal = controller.signal
|
||||||
|
controller.wait = async (waitFor = inv => inv?.actionState === 'PAID') => {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
let updatedInvoice, paid
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
({ invoice: updatedInvoice, check: paid } = await isInvoice(inv, waitFor))
|
||||||
|
if (paid) {
|
||||||
|
resolve(updatedInvoice)
|
||||||
|
clearInterval(interval)
|
||||||
|
signal.removeEventListener('abort', abort)
|
||||||
|
} else {
|
||||||
|
console.info(`invoice #${inv.id}: waiting for payment ...`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
clearInterval(interval)
|
||||||
|
signal.removeEventListener('abort', abort)
|
||||||
|
}
|
||||||
|
}, FAST_POLL_INTERVAL)
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
console.info(`invoice #${inv.id}: stopped waiting`)
|
||||||
|
resolve(updatedInvoice)
|
||||||
|
clearInterval(interval)
|
||||||
|
signal.removeEventListener('abort', abort)
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', abort)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.stop = () => controller.abort()
|
||||||
|
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSendPayment () {
|
||||||
|
const factory = useWalletLoggerFactory()
|
||||||
|
|
||||||
|
return useCallback(async (wallet, invoice) => {
|
||||||
|
const logger = factory(wallet)
|
||||||
|
|
||||||
|
if (!wallet.config.enabled) {
|
||||||
|
throw new WalletNotEnabledError(wallet.def.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canSend(wallet)) {
|
||||||
|
throw new WalletSendNotConfiguredError(wallet.def.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bolt11, satsRequested } = invoice
|
||||||
|
|
||||||
|
logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 })
|
||||||
|
try {
|
||||||
|
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
|
||||||
|
logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.message || err.toString?.()
|
||||||
|
logger.error(`payment failed: ${message}`, { bolt11 })
|
||||||
|
throw new WalletSenderError(wallet.def.name, invoice, message)
|
||||||
|
}
|
||||||
|
}, [factory])
|
||||||
|
}
|
Loading…
Reference in New Issue