Use HODL invoices (#432)
* Use HODL invoices * Fix expiry check comparing string with Date * Fix unconfirmed user balance for HODL invoices This is done by syncing the data from LND to the Invoice table. If the columns is_held and msatsReceived are set, the frontend is told that we're ready to execute the action. We then update the user balance in the same tx as the action. We need to still keep checking the invoice for expiration though. * Fix worker acting upon deleted invoices * Prevent usage of invoice after expiration * Use onComplete from <Countdown> to show expired status * Remove unused lnd argument * Fix item destructuring from query * Fix balance added to every stacker * Fix hmac required * Fix invoices not used when logged in * refactor: move invoiceable code into form * renamed invoiceHash, invoiceHmac to hash, hmac since it's less verbose all over the place * form now supports `invoiceable` in its props * form then wraps `onSubmit` with `useInvoiceable` and passes optional invoice options * Show expired if expired and canceled * Also use useCallback for zapping * Always expire modal invoices after 3m * little styling thing --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
parent
c6dfd1e39c
commit
ac45fdc234
@ -17,6 +17,7 @@ import { sendUserNotification } from '../webPush'
|
|||||||
import { proxyImages } from './imgproxy'
|
import { proxyImages } from './imgproxy'
|
||||||
import { defaultCommentSort } from '../../lib/item'
|
import { defaultCommentSort } from '../../lib/item'
|
||||||
import { createHmac } from './wallet'
|
import { createHmac } from './wallet'
|
||||||
|
import { settleHodlInvoice } from 'ln-service'
|
||||||
|
|
||||||
export async function commentFilterClause (me, models) {
|
export async function commentFilterClause (me, models) {
|
||||||
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
||||||
@ -52,15 +53,27 @@ async function checkInvoice (models, hash, hmac, fee) {
|
|||||||
user: true
|
user: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expired = new Date(invoice.expiresAt) <= new Date()
|
||||||
|
if (expired) {
|
||||||
|
throw new GraphQLError('invoice expired', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.cancelled) {
|
||||||
|
throw new GraphQLError('invoice was canceled', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
|
||||||
if (!invoice.msatsReceived) {
|
if (!invoice.msatsReceived) {
|
||||||
throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
if (msatsToSats(invoice.msatsReceived) < fee) {
|
if (fee && msatsToSats(invoice.msatsReceived) < fee) {
|
||||||
throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
return invoice
|
return invoice
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -604,34 +617,34 @@ export default {
|
|||||||
|
|
||||||
return await models.item.update({ where: { id: Number(id) }, data })
|
return await models.item.update({ where: { id: Number(id) }, data })
|
||||||
},
|
},
|
||||||
upsertLink: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
|
upsertLink: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
||||||
await ssValidate(linkSchema, item, models)
|
await ssValidate(linkSchema, item, models)
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return await updateItem(parent, { id, ...item }, { me, models })
|
return await updateItem(parent, { id, ...item }, { me, models })
|
||||||
} else {
|
} else {
|
||||||
return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
|
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertDiscussion: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
|
upsertDiscussion: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
||||||
await ssValidate(discussionSchema, item, models)
|
await ssValidate(discussionSchema, item, models)
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return await updateItem(parent, { id, ...item }, { me, models })
|
return await updateItem(parent, { id, ...item }, { me, models })
|
||||||
} else {
|
} else {
|
||||||
return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
|
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertBounty: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
|
upsertBounty: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
||||||
await ssValidate(bountySchema, item, models)
|
await ssValidate(bountySchema, item, models)
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return await updateItem(parent, { id, ...item }, { me, models })
|
return await updateItem(parent, { id, ...item }, { me, models })
|
||||||
} else {
|
} else {
|
||||||
return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
|
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertPoll: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
|
upsertPoll: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
||||||
const optionCount = id
|
const optionCount = id
|
||||||
? await models.pollOption.count({
|
? await models.pollOption.count({
|
||||||
where: {
|
where: {
|
||||||
@ -646,10 +659,10 @@ export default {
|
|||||||
return await updateItem(parent, { id, ...item }, { me, models })
|
return await updateItem(parent, { id, ...item }, { me, models })
|
||||||
} else {
|
} else {
|
||||||
item.pollCost = item.pollCost || POLL_COST
|
item.pollCost = item.pollCost || POLL_COST
|
||||||
return await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
|
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertJob: async (parent, { id, ...item }, { me, models }) => {
|
upsertJob: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
|
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
@ -665,16 +678,16 @@ export default {
|
|||||||
if (id) {
|
if (id) {
|
||||||
return await updateItem(parent, { id, ...item }, { me, models })
|
return await updateItem(parent, { id, ...item }, { me, models })
|
||||||
} else {
|
} else {
|
||||||
return await createItem(parent, item, { me, models })
|
return await createItem(parent, item, { me, models, lnd, hash, hmac })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertComment: async (parent, { id, invoiceHash, invoiceHmac, ...item }, { me, models, lnd }) => {
|
upsertComment: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
|
||||||
await ssValidate(commentSchema, item)
|
await ssValidate(commentSchema, item)
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return await updateItem(parent, { id, ...item }, { me, models })
|
return await updateItem(parent, { id, ...item }, { me, models })
|
||||||
} else {
|
} else {
|
||||||
const rItem = await createItem(parent, item, { me, models, lnd, invoiceHash, invoiceHmac })
|
const rItem = await createItem(parent, item, { me, models, lnd, hash, hmac })
|
||||||
|
|
||||||
const notify = async () => {
|
const notify = async () => {
|
||||||
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
|
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
|
||||||
@ -706,19 +719,19 @@ export default {
|
|||||||
|
|
||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
act: async (parent, { id, sats, invoiceHash, invoiceHmac }, { me, models }) => {
|
act: async (parent, { id, sats, hash, hmac }, { me, models, lnd }) => {
|
||||||
// need to make sure we are logged in
|
// need to make sure we are logged in
|
||||||
if (!me && !invoiceHash) {
|
if (!me && !hash) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
await ssValidate(amountSchema, { amount: sats })
|
await ssValidate(amountSchema, { amount: sats })
|
||||||
|
|
||||||
let user = me
|
let user = me
|
||||||
let invoice
|
let invoice
|
||||||
if (!me && invoiceHash) {
|
if (hash) {
|
||||||
invoice = await checkInvoice(models, invoiceHash, invoiceHmac, sats)
|
invoice = await checkInvoice(models, hash, hmac, sats)
|
||||||
user = invoice.user
|
if (!me) user = invoice.user
|
||||||
}
|
}
|
||||||
|
|
||||||
// disallow self tips except anons
|
// disallow self tips except anons
|
||||||
@ -738,14 +751,18 @@ export default {
|
|||||||
throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const calls = [
|
const trx = [
|
||||||
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
|
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
|
||||||
]
|
]
|
||||||
if (invoice) {
|
if (invoice) {
|
||||||
calls.push(models.invoice.delete({ where: { hash: invoice.hash } }))
|
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
|
||||||
|
trx.push(models.invoice.delete({ where: { hash: invoice.hash } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ item_act: vote }] = await serialize(models, ...calls)
|
const query = await serialize(models, ...trx)
|
||||||
|
const { item_act: vote } = trx.length > 1 ? query[1][0] : query[0]
|
||||||
|
|
||||||
|
if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd })
|
||||||
|
|
||||||
const notify = async () => {
|
const notify = async () => {
|
||||||
try {
|
try {
|
||||||
@ -1098,24 +1115,27 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, invoiceHash, invoiceHmac }) => {
|
export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
|
||||||
let spamInterval = ITEM_SPAM_INTERVAL
|
const spamInterval = me ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL
|
||||||
const trx = []
|
|
||||||
|
|
||||||
// rename to match column name
|
// rename to match column name
|
||||||
item.subName = item.sub
|
item.subName = item.sub
|
||||||
delete item.sub
|
delete item.sub
|
||||||
|
|
||||||
|
if (!me && !hash) {
|
||||||
|
throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } })
|
||||||
|
}
|
||||||
|
let invoice
|
||||||
|
if (hash) {
|
||||||
|
// if we are logged in, we don't compare the invoice amount with the fee
|
||||||
|
// since it's not a fixed amount that we could use here.
|
||||||
|
// we rely on the query telling us if the balance is too low
|
||||||
|
const fee = !me ? (item.parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) : undefined
|
||||||
|
invoice = await checkInvoice(models, hash, hmac, fee)
|
||||||
|
item.userId = invoice.user.id
|
||||||
|
}
|
||||||
if (me) {
|
if (me) {
|
||||||
item.userId = Number(me.id)
|
item.userId = Number(me.id)
|
||||||
} else {
|
|
||||||
if (!invoiceHash) {
|
|
||||||
throw new GraphQLError('you must be logged in or pay', { extensions: { code: 'FORBIDDEN' } })
|
|
||||||
}
|
|
||||||
const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, item.parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
|
|
||||||
item.userId = invoice.user.id
|
|
||||||
spamInterval = ANON_ITEM_SPAM_INTERVAL
|
|
||||||
trx.push(models.invoice.delete({ where: { hash: invoiceHash } }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fwdUsers = await getForwardUsers(models, forward)
|
const fwdUsers = await getForwardUsers(models, forward)
|
||||||
@ -1128,12 +1148,19 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
|
|||||||
item.url = await proxyImages(item.url)
|
item.url = await proxyImages(item.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [result] = await serialize(
|
const trx = [
|
||||||
models,
|
|
||||||
models.$queryRawUnsafe(`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`,
|
models.$queryRawUnsafe(`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`,
|
||||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)),
|
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options))
|
||||||
...trx)
|
]
|
||||||
item = Array.isArray(result) ? result[0] : result
|
if (invoice) {
|
||||||
|
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
|
||||||
|
trx.push(models.invoice.delete({ where: { hash: invoice.hash } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = await serialize(models, ...trx)
|
||||||
|
item = trx.length > 1 ? query[1][0] : query[0]
|
||||||
|
|
||||||
|
if (invoice?.isHeld) await settleHodlInvoice({ secret: invoice.preimage, lnd })
|
||||||
|
|
||||||
await createMentions(item, models)
|
await createMentions(item, models)
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service'
|
import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice } from 'ln-service'
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import serialize from './serial'
|
import serialize from './serial'
|
||||||
@ -11,7 +11,7 @@ import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../l
|
|||||||
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT } from '../../lib/constants'
|
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT } from '../../lib/constants'
|
||||||
import { datePivot } from '../../lib/time'
|
import { datePivot } from '../../lib/time'
|
||||||
|
|
||||||
export async function getInvoice (parent, { id }, { me, models }) {
|
export async function getInvoice (parent, { id }, { me, models, lnd }) {
|
||||||
const inv = await models.invoice.findUnique({
|
const inv = await models.invoice.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: Number(id)
|
id: Number(id)
|
||||||
@ -24,6 +24,7 @@ export async function getInvoice (parent, { id }, { me, models }) {
|
|||||||
if (!inv) {
|
if (!inv) {
|
||||||
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inv.user.id === ANON_USER_ID) {
|
if (inv.user.id === ANON_USER_ID) {
|
||||||
return inv
|
return inv
|
||||||
}
|
}
|
||||||
@ -223,7 +224,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
Mutation: {
|
||||||
createInvoice: async (parent, { amount, expireSecs = 3600 }, { me, models, lnd }) => {
|
createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd }) => {
|
||||||
await ssValidate(amountSchema, { amount })
|
await ssValidate(amountSchema, { amount })
|
||||||
|
|
||||||
let expirePivot = { seconds: expireSecs }
|
let expirePivot = { seconds: expireSecs }
|
||||||
@ -242,7 +243,7 @@ export default {
|
|||||||
const expiresAt = datePivot(new Date(), expirePivot)
|
const expiresAt = datePivot(new Date(), expirePivot)
|
||||||
const description = `Funding @${user.name} on stacker.news`
|
const description = `Funding @${user.name} on stacker.news`
|
||||||
try {
|
try {
|
||||||
const invoice = await createInvoice({
|
const invoice = await (hodlInvoice ? createHodlInvoice : createInvoice)({
|
||||||
description: user.hideInvoiceDesc ? undefined : description,
|
description: user.hideInvoiceDesc ? undefined : description,
|
||||||
lnd,
|
lnd,
|
||||||
tokens: amount,
|
tokens: amount,
|
||||||
@ -254,6 +255,8 @@ export default {
|
|||||||
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description},
|
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description},
|
||||||
${invLimit}::INTEGER, ${balanceLimit})`)
|
${invLimit}::INTEGER, ${balanceLimit})`)
|
||||||
|
|
||||||
|
if (hodlInvoice) await models.invoice.update({ where: { hash: invoice.id }, data: { preimage: invoice.secret } })
|
||||||
|
|
||||||
// the HMAC is only returned during invoice creation
|
// the HMAC is only returned during invoice creation
|
||||||
// this makes sure that only the person who created this invoice
|
// this makes sure that only the person who created this invoice
|
||||||
// has access to the HMAC
|
// has access to the HMAC
|
||||||
@ -312,6 +315,23 @@ export default {
|
|||||||
|
|
||||||
// take pr and createWithdrawl
|
// take pr and createWithdrawl
|
||||||
return await createWithdrawal(parent, { invoice: res2.pr, maxFee }, { me, models, lnd })
|
return await createWithdrawal(parent, { invoice: res2.pr, maxFee }, { me, models, lnd })
|
||||||
|
},
|
||||||
|
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
|
||||||
|
const hmac2 = createHmac(hash)
|
||||||
|
if (hmac !== hmac2) {
|
||||||
|
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
|
||||||
|
}
|
||||||
|
await cancelHodlInvoice({ id: hash, lnd })
|
||||||
|
const inv = await serialize(models,
|
||||||
|
models.invoice.update({
|
||||||
|
where: {
|
||||||
|
hash
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
cancelled: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return inv
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -26,15 +26,15 @@ export default gql`
|
|||||||
bookmarkItem(id: ID): Item
|
bookmarkItem(id: ID): Item
|
||||||
subscribeItem(id: ID): Item
|
subscribeItem(id: ID): Item
|
||||||
deleteItem(id: ID): Item
|
deleteItem(id: ID): Item
|
||||||
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
|
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
|
||||||
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
|
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
|
||||||
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: [ItemForwardInput]): Item!
|
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): Item!
|
||||||
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
|
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
|
||||||
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
|
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item!
|
||||||
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], invoiceHash: String, invoiceHmac: String): Item!
|
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
|
||||||
upsertComment(id:ID, text: String!, parentId: ID, invoiceHash: String, invoiceHmac: String): Item!
|
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
|
||||||
dontLikeThis(id: ID!): Boolean!
|
dontLikeThis(id: ID!): Boolean!
|
||||||
act(id: ID!, sats: Int, invoiceHash: String, invoiceHmac: String): ItemActResult!
|
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
|
||||||
pollVote(id: ID!): ID!
|
pollVote(id: ID!): ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,9 +9,10 @@ export default gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
createInvoice(amount: Int!, expireSecs: Int): Invoice!
|
createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice!
|
||||||
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
|
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
|
||||||
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!): Withdrawl!
|
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!): Withdrawl!
|
||||||
|
cancelInvoice(hash: String!, hmac: String!): Invoice!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Invoice {
|
type Invoice {
|
||||||
@ -26,6 +27,7 @@ export default gql`
|
|||||||
satsRequested: Int!
|
satsRequested: Int!
|
||||||
nostr: JSONObject
|
nostr: JSONObject
|
||||||
hmac: String
|
hmac: String
|
||||||
|
isHeld: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Withdrawl {
|
type Withdrawl {
|
||||||
|
@ -9,7 +9,6 @@ import { bountySchema } from '../lib/validate'
|
|||||||
import { SubSelectInitial } from './sub-select-form'
|
import { SubSelectInitial } from './sub-select-form'
|
||||||
import CancelButton from './cancel-button'
|
import CancelButton from './cancel-button'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useInvoiceable } from './invoice'
|
|
||||||
import { normalizeForwards } from '../lib/form'
|
import { normalizeForwards } from '../lib/form'
|
||||||
|
|
||||||
export function BountyForm ({
|
export function BountyForm ({
|
||||||
@ -36,6 +35,8 @@ export function BountyForm ({
|
|||||||
$text: String
|
$text: String
|
||||||
$boost: Int
|
$boost: Int
|
||||||
$forward: [ItemForwardInput]
|
$forward: [ItemForwardInput]
|
||||||
|
$hash: String
|
||||||
|
$hmac: String
|
||||||
) {
|
) {
|
||||||
upsertBounty(
|
upsertBounty(
|
||||||
sub: $sub
|
sub: $sub
|
||||||
@ -45,6 +46,8 @@ export function BountyForm ({
|
|||||||
text: $text
|
text: $text
|
||||||
boost: $boost
|
boost: $boost
|
||||||
forward: $forward
|
forward: $forward
|
||||||
|
hash: $hash
|
||||||
|
hmac: $hmac
|
||||||
) {
|
) {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
@ -52,9 +55,8 @@ export function BountyForm ({
|
|||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitUpsertBounty = useCallback(
|
const onSubmit = useCallback(
|
||||||
// we ignore the invoice since only stackers can post bounties
|
async ({ boost, bounty, ...values }) => {
|
||||||
async (_, boost, bounty, values, ...__) => {
|
|
||||||
const { error } = await upsertBounty({
|
const { error } = await upsertBounty({
|
||||||
variables: {
|
variables: {
|
||||||
sub: item?.subName || sub?.name,
|
sub: item?.subName || sub?.name,
|
||||||
@ -75,9 +77,8 @@ export function BountyForm ({
|
|||||||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||||
await router.push(prefix + '/recent')
|
await router.push(prefix + '/recent')
|
||||||
}
|
}
|
||||||
}, [upsertBounty, router])
|
}, [upsertBounty, router]
|
||||||
|
)
|
||||||
const invoiceableUpsertBounty = useInvoiceable(submitUpsertBounty, { requireSession: true })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
@ -89,11 +90,10 @@ export function BountyForm ({
|
|||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
|
invoiceable={{ requireSession: true }}
|
||||||
onSubmit={
|
onSubmit={
|
||||||
handleSubmit ||
|
handleSubmit ||
|
||||||
(async ({ boost, bounty, cost, ...values }) => {
|
onSubmit
|
||||||
return invoiceableUpsertBounty(cost, boost, bounty, values)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
storageKeyPrefix={item ? undefined : 'bounty'}
|
storageKeyPrefix={item ? undefined : 'bounty'}
|
||||||
>
|
>
|
||||||
|
@ -13,7 +13,6 @@ import { discussionSchema } from '../lib/validate'
|
|||||||
import { SubSelectInitial } from './sub-select-form'
|
import { SubSelectInitial } from './sub-select-form'
|
||||||
import CancelButton from './cancel-button'
|
import CancelButton from './cancel-button'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useInvoiceable } from './invoice'
|
|
||||||
import { normalizeForwards } from '../lib/form'
|
import { normalizeForwards } from '../lib/form'
|
||||||
|
|
||||||
export function DiscussionForm ({
|
export function DiscussionForm ({
|
||||||
@ -30,24 +29,22 @@ export function DiscussionForm ({
|
|||||||
// const me = useMe()
|
// const me = useMe()
|
||||||
const [upsertDiscussion] = useMutation(
|
const [upsertDiscussion] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) {
|
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
|
||||||
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitUpsertDiscussion = useCallback(
|
const onSubmit = useCallback(
|
||||||
async (_, boost, values, invoiceHash, invoiceHmac) => {
|
async ({ boost, ...values }) => {
|
||||||
const { error } = await upsertDiscussion({
|
const { error } = await upsertDiscussion({
|
||||||
variables: {
|
variables: {
|
||||||
sub: item?.subName || sub?.name,
|
sub: item?.subName || sub?.name,
|
||||||
id: item?.id,
|
id: item?.id,
|
||||||
boost: boost ? Number(boost) : undefined,
|
boost: boost ? Number(boost) : undefined,
|
||||||
...values,
|
...values,
|
||||||
forward: normalizeForwards(values.forward),
|
forward: normalizeForwards(values.forward)
|
||||||
invoiceHash,
|
|
||||||
invoiceHmac
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -60,9 +57,8 @@ export function DiscussionForm ({
|
|||||||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||||
await router.push(prefix + '/recent')
|
await router.push(prefix + '/recent')
|
||||||
}
|
}
|
||||||
}, [upsertDiscussion, router])
|
}, [upsertDiscussion, router]
|
||||||
|
)
|
||||||
const invoiceableUpsertDiscussion = useInvoiceable(submitUpsertDiscussion)
|
|
||||||
|
|
||||||
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
|
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
|
||||||
${ITEM_FIELDS}
|
${ITEM_FIELDS}
|
||||||
@ -87,9 +83,8 @@ export function DiscussionForm ({
|
|||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
onSubmit={handleSubmit || (async ({ boost, cost, ...values }) => {
|
invoiceable
|
||||||
return invoiceableUpsertDiscussion(cost, boost, values)
|
onSubmit={handleSubmit || onSubmit}
|
||||||
})}
|
|
||||||
storageKeyPrefix={item ? undefined : 'discussion'}
|
storageKeyPrefix={item ? undefined : 'discussion'}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -19,19 +19,24 @@ import { useLazyQuery } from '@apollo/client'
|
|||||||
import { USER_SEARCH } from '../fragments/users'
|
import { USER_SEARCH } from '../fragments/users'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
import TextareaAutosize from 'react-textarea-autosize'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
|
import { useInvoiceable } from './invoice'
|
||||||
|
|
||||||
export function SubmitButton ({
|
export function SubmitButton ({
|
||||||
children, variant, value, onClick, disabled, ...props
|
children, variant, value, onClick, disabled, cost, ...props
|
||||||
}) {
|
}) {
|
||||||
const { isSubmitting, setFieldValue } = useFormikContext()
|
const formik = useFormikContext()
|
||||||
|
useEffect(() => {
|
||||||
|
formik?.setFieldValue('cost', cost)
|
||||||
|
}, [formik?.setFieldValue, formik?.getFieldProps('cost').value, cost])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant={variant || 'main'}
|
variant={variant || 'main'}
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={disabled || isSubmitting}
|
disabled={disabled || formik.isSubmitting}
|
||||||
onClick={value
|
onClick={value
|
||||||
? e => {
|
? e => {
|
||||||
setFieldValue('submit', value)
|
formik.setFieldValue('submit', value)
|
||||||
onClick && onClick(e)
|
onClick && onClick(e)
|
||||||
}
|
}
|
||||||
: onClick}
|
: onClick}
|
||||||
@ -470,7 +475,7 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
|
|||||||
const StorageKeyPrefixContext = createContext()
|
const StorageKeyPrefixContext = createContext()
|
||||||
|
|
||||||
export function Form ({
|
export function Form ({
|
||||||
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, ...props
|
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, ...props
|
||||||
}) {
|
}) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -479,6 +484,16 @@ export function Form ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// if `invoiceable` is set,
|
||||||
|
// support for payment per invoice if they are lurking or don't have enough balance
|
||||||
|
// is added to submit handlers.
|
||||||
|
// submit handlers need to accept { satsReceived, hash, hmac } in their first argument
|
||||||
|
// and use them as variables in their GraphQL mutation
|
||||||
|
if (invoiceable && onSubmit) {
|
||||||
|
const options = typeof invoiceable === 'object' ? invoiceable : undefined
|
||||||
|
onSubmit = useInvoiceable(onSubmit, options)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initial}
|
initialValues={initial}
|
||||||
|
@ -16,15 +16,16 @@ export default function FundError ({ onClose, amount, onPayment }) {
|
|||||||
<Button variant='success' onClick={onClose}>fund wallet</Button>
|
<Button variant='success' onClick={onClose}>fund wallet</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<span className='d-flex mx-3 fw-bold text-muted align-items-center'>or</span>
|
<span className='d-flex mx-3 fw-bold text-muted align-items-center'>or</span>
|
||||||
<Button variant='success' onClick={() => createInvoice(amount).catch(err => setError(err.message || err))}>pay invoice</Button>
|
<Button variant='success' onClick={() => createInvoice({ amount }).catch(err => setError(err.message || err))}>pay invoice</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isInsufficientFundsError = (error) => {
|
export const payOrLoginError = (error) => {
|
||||||
|
const matches = ['insufficient funds', 'you must be logged in or pay']
|
||||||
if (Array.isArray(error)) {
|
if (Array.isArray(error)) {
|
||||||
return error.some(({ message }) => message.includes('insufficient funds'))
|
return error.some(({ message }) => matches.some(m => message.includes(m)))
|
||||||
}
|
}
|
||||||
return error.toString().includes('insufficient funds')
|
return matches.some(m => error.toString().includes(m))
|
||||||
}
|
}
|
||||||
|
@ -5,38 +5,39 @@ import { gql } from 'graphql-tag'
|
|||||||
import { numWithUnits } from '../lib/format'
|
import { numWithUnits } from '../lib/format'
|
||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import Qr, { QrSkeleton } from './qr'
|
import Qr, { QrSkeleton } from './qr'
|
||||||
import { CopyInput } from './form'
|
|
||||||
import { INVOICE } from '../fragments/wallet'
|
import { INVOICE } from '../fragments/wallet'
|
||||||
import InvoiceStatus from './invoice-status'
|
import InvoiceStatus from './invoice-status'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { sleep } from '../lib/time'
|
import { sleep } from '../lib/time'
|
||||||
import FundError, { isInsufficientFundsError } from './fund-error'
|
import FundError, { payOrLoginError } from './fund-error'
|
||||||
import { usePaymentTokens } from './payment-tokens'
|
import Countdown from './countdown'
|
||||||
|
|
||||||
|
export function Invoice ({ invoice, onPayment, successVerb }) {
|
||||||
|
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
|
||||||
|
|
||||||
export function Invoice ({ invoice, onConfirmation, successVerb }) {
|
|
||||||
let variant = 'default'
|
let variant = 'default'
|
||||||
let status = 'waiting for you'
|
let status = 'waiting for you'
|
||||||
let webLn = true
|
let webLn = true
|
||||||
if (invoice.confirmedAt) {
|
if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
|
||||||
variant = 'confirmed'
|
variant = 'confirmed'
|
||||||
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
|
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
|
||||||
webLn = false
|
webLn = false
|
||||||
|
} else if (expired) {
|
||||||
|
variant = 'failed'
|
||||||
|
status = 'expired'
|
||||||
|
webLn = false
|
||||||
} else if (invoice.cancelled) {
|
} else if (invoice.cancelled) {
|
||||||
variant = 'failed'
|
variant = 'failed'
|
||||||
status = 'cancelled'
|
status = 'cancelled'
|
||||||
webLn = false
|
webLn = false
|
||||||
} else if (invoice.expiresAt <= new Date()) {
|
|
||||||
variant = 'failed'
|
|
||||||
status = 'expired'
|
|
||||||
webLn = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoice.confirmedAt) {
|
if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived)) {
|
||||||
onConfirmation?.(invoice)
|
onPayment?.(invoice)
|
||||||
}
|
}
|
||||||
}, [invoice.confirmedAt])
|
}, [invoice.confirmedAt, invoice.isHeld, invoice.satsReceived])
|
||||||
|
|
||||||
const { nostr } = invoice
|
const { nostr } = invoice
|
||||||
|
|
||||||
@ -47,6 +48,13 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) {
|
|||||||
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
||||||
statusVariant={variant} status={status}
|
statusVariant={variant} status={status}
|
||||||
/>
|
/>
|
||||||
|
<div className='text-muted text-center'>
|
||||||
|
<Countdown
|
||||||
|
date={invoice.expiresAt} onComplete={() => {
|
||||||
|
setExpired(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className='w-100'>
|
<div className='w-100'>
|
||||||
{nostr
|
{nostr
|
||||||
? <AccordianItem
|
? <AccordianItem
|
||||||
@ -65,55 +73,18 @@ export function Invoice ({ invoice, onConfirmation, successVerb }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Contacts = ({ invoiceHash, invoiceHmac }) => {
|
const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresAt, ...props }) => {
|
||||||
const subject = `Support request for payment hash: ${invoiceHash}`
|
|
||||||
const body = 'Hi, I successfully paid for <insert action> but the action did not work.'
|
|
||||||
return (
|
|
||||||
<div className='d-flex flex-column justify-content-center mt-2'>
|
|
||||||
<div className='w-100'>
|
|
||||||
<CopyInput
|
|
||||||
label={<>payment token <small className='text-danger fw-normal ms-2'>save this</small></>}
|
|
||||||
type='text' placeholder={invoiceHash + '|' + invoiceHmac} readOnly noForm
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='d-flex flex-row justify-content-center'>
|
|
||||||
<a
|
|
||||||
href={`mailto:kk@stacker.news?subject=${subject}&body=${body}`} className='nav-link p-0 d-inline-flex'
|
|
||||||
target='_blank' rel='noreferrer'
|
|
||||||
>
|
|
||||||
e-mail
|
|
||||||
</a>
|
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
|
||||||
<a
|
|
||||||
href='https://tribes.sphinx.chat/t/stackerzchat' className='nav-link p-0 d-inline-flex'
|
|
||||||
target='_blank' rel='noreferrer'
|
|
||||||
>
|
|
||||||
sphinx
|
|
||||||
</a>
|
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
|
||||||
<a
|
|
||||||
href='https://t.me/k00bideh' className='nav-link p-0 d-inline-flex'
|
|
||||||
target='_blank' rel='noreferrer'
|
|
||||||
>
|
|
||||||
telegram
|
|
||||||
</a>
|
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
|
||||||
<a
|
|
||||||
href='https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE%3D%40smp10.simplex.im%2FebLYaEFGjsD3uK4fpE326c5QI1RZSxau%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAV086Oj5yCsavWzIbRMCVuF6jq793Tt__rWvCec__viI%253D%26srv%3Drb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22cZwSGoQhyOUulzp7rwCdWQ%3D%3D%22%7D' className='nav-link p-0 d-inline-flex'
|
|
||||||
target='_blank' rel='noreferrer'
|
|
||||||
>
|
|
||||||
simplex
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => {
|
|
||||||
const { data, loading, error } = useQuery(INVOICE, {
|
const { data, loading, error } = useQuery(INVOICE, {
|
||||||
pollInterval: 1000,
|
pollInterval: 1000,
|
||||||
variables: { id }
|
variables: { id }
|
||||||
})
|
})
|
||||||
|
const [cancelInvoice] = useMutation(gql`
|
||||||
|
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
||||||
|
cancelInvoice(hash: $hash, hmac: $hmac) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.message?.includes('invoice not found')) {
|
if (error.message?.includes('invoice not found')) {
|
||||||
return
|
return
|
||||||
@ -126,7 +97,7 @@ const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => {
|
|||||||
|
|
||||||
let errorStatus = 'Something went wrong trying to perform the action after payment.'
|
let errorStatus = 'Something went wrong trying to perform the action after payment.'
|
||||||
if (errorCount > 1) {
|
if (errorCount > 1) {
|
||||||
errorStatus = 'Something still went wrong.\nPlease contact admins for support or to request a refund.'
|
errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.'
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -137,8 +108,17 @@ const ActionInvoice = ({ id, hash, hmac, errorCount, repeat, ...props }) => {
|
|||||||
<div className='my-3'>
|
<div className='my-3'>
|
||||||
<InvoiceStatus variant='failed' status={errorStatus} />
|
<InvoiceStatus variant='failed' status={errorStatus} />
|
||||||
</div>
|
</div>
|
||||||
<div className='d-flex flex-row mt-3 justify-content-center'><Button variant='info' onClick={repeat}>Retry</Button></div>
|
<div className='d-flex flex-row mt-3 justify-content-center'>
|
||||||
<Contacts invoiceHash={hash} invoiceHmac={hmac} />
|
<Button className='mx-1' variant='info' onClick={repeat}>Retry</Button>
|
||||||
|
<Button
|
||||||
|
className='mx-1'
|
||||||
|
variant='danger' onClick={async () => {
|
||||||
|
await cancelInvoice({ variables: { hash, hmac } })
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
@ -150,64 +130,67 @@ const defaultOptions = {
|
|||||||
forceInvoice: false,
|
forceInvoice: false,
|
||||||
requireSession: false
|
requireSession: false
|
||||||
}
|
}
|
||||||
export const useInvoiceable = (fn, options = defaultOptions) => {
|
export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const [createInvoice, { data }] = useMutation(gql`
|
const [createInvoice, { data }] = useMutation(gql`
|
||||||
mutation createInvoice($amount: Int!) {
|
mutation createInvoice($amount: Int!) {
|
||||||
createInvoice(amount: $amount, expireSecs: 1800) {
|
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) {
|
||||||
id
|
id
|
||||||
hash
|
hash
|
||||||
hmac
|
hmac
|
||||||
|
expiresAt
|
||||||
}
|
}
|
||||||
}`)
|
}`)
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const [fnArgs, setFnArgs] = useState()
|
const [formValues, setFormValues] = useState()
|
||||||
const { addPaymentToken, removePaymentToken } = usePaymentTokens()
|
const [submitArgs, setSubmitArgs] = useState()
|
||||||
|
|
||||||
// fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice
|
|
||||||
let errorCount = 0
|
let errorCount = 0
|
||||||
const onConfirmation = useCallback(
|
const onPayment = useCallback(
|
||||||
(onClose, hmac) => {
|
(onClose, hmac) => {
|
||||||
return async ({ id, satsReceived, hash }) => {
|
return async ({ id, satsReceived, expiresAt, hash }) => {
|
||||||
addPaymentToken(hash, hmac, satsReceived)
|
|
||||||
await sleep(500)
|
await sleep(500)
|
||||||
const repeat = () =>
|
const repeat = () =>
|
||||||
fn(satsReceived, ...fnArgs, hash, hmac)
|
// call onSubmit handler and pass invoice data
|
||||||
.then(() => {
|
onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs)
|
||||||
removePaymentToken(hash, hmac)
|
|
||||||
})
|
|
||||||
.then(onClose)
|
.then(onClose)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
// if error happened after payment, show repeat and cancel options
|
||||||
|
// by passing `errorCount` and `repeat`
|
||||||
console.error(error)
|
console.error(error)
|
||||||
errorCount++
|
errorCount++
|
||||||
onClose()
|
onClose()
|
||||||
showModal(onClose => (
|
showModal(onClose => (
|
||||||
<ActionInvoice
|
<MutationInvoice
|
||||||
id={id}
|
id={id}
|
||||||
hash={hash}
|
hash={hash}
|
||||||
hmac={hmac}
|
hmac={hmac}
|
||||||
onConfirmation={onConfirmation(onClose, hmac)}
|
expiresAt={expiresAt}
|
||||||
|
onClose={onClose}
|
||||||
|
onPayment={onPayment(onClose, hmac)}
|
||||||
successVerb='received'
|
successVerb='received'
|
||||||
errorCount={errorCount}
|
errorCount={errorCount}
|
||||||
repeat={repeat}
|
repeat={repeat}
|
||||||
/>
|
/>
|
||||||
), { keepOpen: true })
|
), { keepOpen: true })
|
||||||
})
|
})
|
||||||
// prevents infinite loop of calling `onConfirmation`
|
// prevents infinite loop of calling `onPayment`
|
||||||
if (errorCount === 0) await repeat()
|
if (errorCount === 0) await repeat()
|
||||||
}
|
}
|
||||||
}, [fn, fnArgs]
|
}, [onSubmit, submitArgs]
|
||||||
)
|
)
|
||||||
|
|
||||||
const invoice = data?.createInvoice
|
const invoice = data?.createInvoice
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoice) {
|
if (invoice) {
|
||||||
showModal(onClose => (
|
showModal(onClose => (
|
||||||
<ActionInvoice
|
<MutationInvoice
|
||||||
id={invoice.id}
|
id={invoice.id}
|
||||||
hash={invoice.hash}
|
hash={invoice.hash}
|
||||||
hmac={invoice.hmac}
|
hmac={invoice.hmac}
|
||||||
onConfirmation={onConfirmation(onClose, invoice.hmac)}
|
expiresAt={invoice.expiresAt}
|
||||||
|
onClose={onClose}
|
||||||
|
onPayment={onPayment(onClose, invoice.hmac)}
|
||||||
successVerb='received'
|
successVerb='received'
|
||||||
/>
|
/>
|
||||||
), { keepOpen: true }
|
), { keepOpen: true }
|
||||||
@ -215,21 +198,31 @@ export const useInvoiceable = (fn, options = defaultOptions) => {
|
|||||||
}
|
}
|
||||||
}, [invoice?.id])
|
}, [invoice?.id])
|
||||||
|
|
||||||
const actionFn = useCallback(async (amount, ...args) => {
|
// this function will be called before the Form's onSubmit handler is called
|
||||||
|
// and the form must include `cost` or `amount` as a value
|
||||||
|
const onSubmitWrapper = useCallback(async (formValues, ...submitArgs) => {
|
||||||
|
let { cost, amount } = formValues
|
||||||
|
cost ??= amount
|
||||||
|
|
||||||
|
// action only allowed if logged in
|
||||||
if (!me && options.requireSession) {
|
if (!me && options.requireSession) {
|
||||||
throw new Error('you must be logged in')
|
throw new Error('you must be logged in')
|
||||||
}
|
}
|
||||||
if (!amount || (me && !options.forceInvoice)) {
|
|
||||||
|
// if no cost is passed, just try the action first
|
||||||
|
if (!cost || (me && !options.forceInvoice)) {
|
||||||
try {
|
try {
|
||||||
return await fn(amount, ...args)
|
return await onSubmit(formValues, ...submitArgs)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isInsufficientFundsError(error)) {
|
if (payOrLoginError(error)) {
|
||||||
showModal(onClose => {
|
showModal(onClose => {
|
||||||
return (
|
return (
|
||||||
<FundError
|
<FundError
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
amount={amount}
|
amount={cost}
|
||||||
onPayment={async (_, invoiceHash, invoiceHmac) => { await fn(amount, ...args, invoiceHash, invoiceHmac) }}
|
onPayment={async ({ satsReceived, hash, hmac }) => {
|
||||||
|
await onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -238,12 +231,13 @@ export const useInvoiceable = (fn, options = defaultOptions) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setFnArgs(args)
|
setFormValues(formValues)
|
||||||
await createInvoice({ variables: { amount } })
|
setSubmitArgs(submitArgs)
|
||||||
|
await createInvoice({ variables: { amount: cost } })
|
||||||
// tell onSubmit handler that we want to keep local storage
|
// tell onSubmit handler that we want to keep local storage
|
||||||
// even though the submit handler was "successful"
|
// even though the submit handler was "successful"
|
||||||
return { keepLocalStorage: true }
|
return { keepLocalStorage: true }
|
||||||
}, [fn, setFnArgs, createInvoice])
|
}, [onSubmit, setFormValues, setSubmitArgs, createInvoice])
|
||||||
|
|
||||||
return actionFn
|
return onSubmitWrapper
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ import { Form, Input, SubmitButton } from './form'
|
|||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import UpBolt from '../svgs/bolt.svg'
|
import UpBolt from '../svgs/bolt.svg'
|
||||||
import { amountSchema } from '../lib/validate'
|
import { amountSchema } from '../lib/validate'
|
||||||
import { useInvoiceable } from './invoice'
|
|
||||||
|
|
||||||
const defaultTips = [100, 1000, 10000, 100000]
|
const defaultTips = [100, 1000, 10000, 100000]
|
||||||
|
|
||||||
@ -46,27 +45,24 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
|
|||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, [onClose, itemId])
|
}, [onClose, itemId])
|
||||||
|
|
||||||
const submitAct = useCallback(
|
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
|
||||||
async (amount, invoiceHash, invoiceHmac) => {
|
if (!me) {
|
||||||
if (!me) {
|
const storageKey = `TIP-item:${itemId}`
|
||||||
const storageKey = `TIP-item:${itemId}`
|
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
|
||||||
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
|
window.localStorage.setItem(storageKey, existingAmount + amount)
|
||||||
window.localStorage.setItem(storageKey, existingAmount + amount)
|
}
|
||||||
|
await act({
|
||||||
|
variables: {
|
||||||
|
id: itemId,
|
||||||
|
sats: Number(amount),
|
||||||
|
hash,
|
||||||
|
hmac
|
||||||
}
|
}
|
||||||
await act({
|
})
|
||||||
variables: {
|
await strike()
|
||||||
id: itemId,
|
addCustomTip(Number(amount))
|
||||||
sats: Number(amount),
|
onClose()
|
||||||
invoiceHash,
|
}, [act])
|
||||||
invoiceHmac
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await strike()
|
|
||||||
addCustomTip(Number(amount))
|
|
||||||
onClose()
|
|
||||||
}, [act, onClose, strike, itemId])
|
|
||||||
|
|
||||||
const invoiceableAct = useInvoiceable(submitAct)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
@ -75,9 +71,8 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
|
|||||||
default: false
|
default: false
|
||||||
}}
|
}}
|
||||||
schema={amountSchema}
|
schema={amountSchema}
|
||||||
onSubmit={async ({ amount }) => {
|
invoiceable
|
||||||
return invoiceableAct(amount)
|
onSubmit={onSubmit}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
label='amount'
|
label='amount'
|
||||||
|
@ -17,7 +17,6 @@ import Avatar from './avatar'
|
|||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import { jobSchema } from '../lib/validate'
|
import { jobSchema } from '../lib/validate'
|
||||||
import CancelButton from './cancel-button'
|
import CancelButton from './cancel-button'
|
||||||
import { useInvoiceable } from './invoice'
|
|
||||||
|
|
||||||
function satsMin2Mo (minute) {
|
function satsMin2Mo (minute) {
|
||||||
return minute * 30 * 24 * 60
|
return minute * 30 * 24 * 60
|
||||||
@ -42,18 +41,17 @@ export default function JobForm ({ item, sub }) {
|
|||||||
const [logoId, setLogoId] = useState(item?.uploadId)
|
const [logoId, setLogoId] = useState(item?.uploadId)
|
||||||
const [upsertJob] = useMutation(gql`
|
const [upsertJob] = useMutation(gql`
|
||||||
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String,
|
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String,
|
||||||
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int) {
|
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int, $hash: String, $hmac: String) {
|
||||||
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
|
upsertJob(sub: $sub, id: $id, title: $title, company: $company,
|
||||||
location: $location, remote: $remote, text: $text,
|
location: $location, remote: $remote, text: $text,
|
||||||
url: $url, maxBid: $maxBid, status: $status, logo: $logo) {
|
url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitUpsertJob = useCallback(
|
const onSubmit = useCallback(
|
||||||
// we ignore the invoice since only stackers can post jobs
|
async ({ maxBid, start, stop, ...values }) => {
|
||||||
async (_, maxBid, stop, start, values, ...__) => {
|
|
||||||
let status
|
let status
|
||||||
if (start) {
|
if (start) {
|
||||||
status = 'ACTIVE'
|
status = 'ACTIVE'
|
||||||
@ -80,9 +78,8 @@ export default function JobForm ({ item, sub }) {
|
|||||||
} else {
|
} else {
|
||||||
await router.push(`/~${sub.name}/recent`)
|
await router.push(`/~${sub.name}/recent`)
|
||||||
}
|
}
|
||||||
}, [upsertJob, router, item?.id, sub?.name, logoId])
|
}, [upsertJob, router]
|
||||||
|
)
|
||||||
const invoiceableUpsertJob = useInvoiceable(submitUpsertJob, { requireSession: true })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -101,9 +98,8 @@ export default function JobForm ({ item, sub }) {
|
|||||||
}}
|
}}
|
||||||
schema={jobSchema}
|
schema={jobSchema}
|
||||||
storageKeyPrefix={storageKeyPrefix}
|
storageKeyPrefix={storageKeyPrefix}
|
||||||
onSubmit={(async ({ maxBid, stop, start, ...values }) => {
|
invoiceable={{ requireSession: true }}
|
||||||
return invoiceableUpsertJob(1000, maxBid, stop, start, values)
|
onSubmit={onSubmit}
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<div className='form-group'>
|
<div className='form-group'>
|
||||||
<label className='form-label'>logo</label>
|
<label className='form-label'>logo</label>
|
||||||
@ -167,7 +163,7 @@ export default function JobForm ({ item, sub }) {
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<ActionTooltip overlayText='1000 sats'>
|
<ActionTooltip overlayText='1000 sats'>
|
||||||
<SubmitButton variant='secondary'>post <small> 1000 sats</small></SubmitButton>
|
<SubmitButton cost={1000} variant='secondary'>post <small> 1000 sats</small></SubmitButton>
|
||||||
</ActionTooltip>
|
</ActionTooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,7 +14,6 @@ import { linkSchema } from '../lib/validate'
|
|||||||
import Moon from '../svgs/moon-fill.svg'
|
import Moon from '../svgs/moon-fill.svg'
|
||||||
import { SubSelectInitial } from './sub-select-form'
|
import { SubSelectInitial } from './sub-select-form'
|
||||||
import CancelButton from './cancel-button'
|
import CancelButton from './cancel-button'
|
||||||
import { useInvoiceable } from './invoice'
|
|
||||||
import { normalizeForwards } from '../lib/form'
|
import { normalizeForwards } from '../lib/form'
|
||||||
|
|
||||||
export function LinkForm ({ item, sub, editThreshold, children }) {
|
export function LinkForm ({ item, sub, editThreshold, children }) {
|
||||||
@ -68,23 +67,21 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
|
|
||||||
const [upsertLink] = useMutation(
|
const [upsertLink] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) {
|
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
|
||||||
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitUpsertLink = useCallback(
|
const onSubmit = useCallback(
|
||||||
async (_, boost, title, values, invoiceHash, invoiceHmac) => {
|
async ({ boost, title, ...values }) => {
|
||||||
const { error } = await upsertLink({
|
const { error } = await upsertLink({
|
||||||
variables: {
|
variables: {
|
||||||
sub: item?.subName || sub?.name,
|
sub: item?.subName || sub?.name,
|
||||||
id: item?.id,
|
id: item?.id,
|
||||||
boost: boost ? Number(boost) : undefined,
|
boost: boost ? Number(boost) : undefined,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
invoiceHash,
|
|
||||||
invoiceHmac,
|
|
||||||
...values,
|
...values,
|
||||||
forward: normalizeForwards(values.forward)
|
forward: normalizeForwards(values.forward)
|
||||||
}
|
}
|
||||||
@ -98,9 +95,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||||
await router.push(prefix + '/recent')
|
await router.push(prefix + '/recent')
|
||||||
}
|
}
|
||||||
}, [upsertLink, router])
|
}, [upsertLink, router]
|
||||||
|
)
|
||||||
const invoiceableUpsertLink = useInvoiceable(submitUpsertLink)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.pageTitleAndUnshorted?.title) {
|
if (data?.pageTitleAndUnshorted?.title) {
|
||||||
@ -128,9 +124,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
onSubmit={async ({ boost, title, cost, ...values }) => {
|
invoiceable
|
||||||
return invoiceableUpsertLink(cost, boost, title, values)
|
onSubmit={onSubmit}
|
||||||
}}
|
|
||||||
storageKeyPrefix={item ? undefined : 'link'}
|
storageKeyPrefix={item ? undefined : 'link'}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
import React, { useCallback, useContext, useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
export const PaymentTokenContext = React.createContext()
|
|
||||||
|
|
||||||
const fetchTokensFromLocalStorage = () => {
|
|
||||||
const tokens = JSON.parse(window.localStorage.getItem('payment-tokens') || '[]')
|
|
||||||
return tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaymentTokenProvider ({ children }) {
|
|
||||||
const [tokens, setTokens] = useState([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTokens(fetchTokensFromLocalStorage())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const addPaymentToken = useCallback((hash, hmac, amount) => {
|
|
||||||
const token = hash + '|' + hmac
|
|
||||||
const newTokens = [...tokens, { token, amount }]
|
|
||||||
window.localStorage.setItem('payment-tokens', JSON.stringify(newTokens))
|
|
||||||
setTokens(newTokens)
|
|
||||||
}, [tokens])
|
|
||||||
|
|
||||||
const removePaymentToken = useCallback((hash, hmac) => {
|
|
||||||
const token = hash + '|' + hmac
|
|
||||||
const newTokens = tokens.filter(({ token: t }) => t !== token)
|
|
||||||
window.localStorage.setItem('payment-tokens', JSON.stringify(newTokens))
|
|
||||||
setTokens(newTokens)
|
|
||||||
}, [tokens])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PaymentTokenContext.Provider value={{ tokens, addPaymentToken, removePaymentToken }}>
|
|
||||||
{children}
|
|
||||||
</PaymentTokenContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePaymentTokens () {
|
|
||||||
return useContext(PaymentTokenContext)
|
|
||||||
}
|
|
@ -11,7 +11,6 @@ import { pollSchema } from '../lib/validate'
|
|||||||
import { SubSelectInitial } from './sub-select-form'
|
import { SubSelectInitial } from './sub-select-form'
|
||||||
import CancelButton from './cancel-button'
|
import CancelButton from './cancel-button'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useInvoiceable } from './invoice'
|
|
||||||
import { normalizeForwards } from '../lib/form'
|
import { normalizeForwards } from '../lib/form'
|
||||||
|
|
||||||
export function PollForm ({ item, sub, editThreshold, children }) {
|
export function PollForm ({ item, sub, editThreshold, children }) {
|
||||||
@ -22,16 +21,16 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
const [upsertPoll] = useMutation(
|
const [upsertPoll] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
|
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
|
||||||
$options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $invoiceHash: String, $invoiceHmac: String) {
|
$options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
|
||||||
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
|
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
|
||||||
options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitUpsertPoll = useCallback(
|
const onSubmit = useCallback(
|
||||||
async (_, boost, title, options, values, invoiceHash, invoiceHmac) => {
|
async ({ boost, title, options, ...values }) => {
|
||||||
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
|
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
|
||||||
const { error } = await upsertPoll({
|
const { error } = await upsertPoll({
|
||||||
variables: {
|
variables: {
|
||||||
@ -41,9 +40,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
options: optionsFiltered,
|
options: optionsFiltered,
|
||||||
...values,
|
...values,
|
||||||
forward: normalizeForwards(values.forward),
|
forward: normalizeForwards(values.forward)
|
||||||
invoiceHash,
|
|
||||||
invoiceHmac
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -55,9 +52,8 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||||
await router.push(prefix + '/recent')
|
await router.push(prefix + '/recent')
|
||||||
}
|
}
|
||||||
}, [upsertPoll, router])
|
}, [upsertPoll, router]
|
||||||
|
)
|
||||||
const invoiceableUpsertPoll = useInvoiceable(submitUpsertPoll)
|
|
||||||
|
|
||||||
const initialOptions = item?.poll?.options.map(i => i.option)
|
const initialOptions = item?.poll?.options.map(i => i.option)
|
||||||
|
|
||||||
@ -71,9 +67,8 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||||
}}
|
}}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
onSubmit={async ({ boost, title, options, cost, ...values }) => {
|
invoiceable
|
||||||
return invoiceableUpsertPoll(cost, boost, title, options, values)
|
onSubmit={onSubmit}
|
||||||
}}
|
|
||||||
storageKeyPrefix={item ? undefined : 'poll'}
|
storageKeyPrefix={item ? undefined : 'poll'}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -9,7 +9,6 @@ import FeeButton from './fee-button'
|
|||||||
import { commentsViewedAfterComment } from '../lib/new-comments'
|
import { commentsViewedAfterComment } from '../lib/new-comments'
|
||||||
import { commentSchema } from '../lib/validate'
|
import { commentSchema } from '../lib/validate'
|
||||||
import Info from './info'
|
import Info from './info'
|
||||||
import { useInvoiceable } from './invoice'
|
|
||||||
|
|
||||||
export function ReplyOnAnotherPage ({ parentId }) {
|
export function ReplyOnAnotherPage ({ parentId }) {
|
||||||
return (
|
return (
|
||||||
@ -46,8 +45,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
|
|||||||
const [upsertComment] = useMutation(
|
const [upsertComment] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
${COMMENTS}
|
${COMMENTS}
|
||||||
mutation upsertComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) {
|
mutation upsertComment($text: String!, $parentId: ID!, $hash: String, $hmac: String) {
|
||||||
upsertComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
upsertComment(text: $text, parentId: $parentId, hash: $hash, hmac: $hmac) {
|
||||||
...CommentFields
|
...CommentFields
|
||||||
comments {
|
comments {
|
||||||
...CommentsRecursive
|
...CommentsRecursive
|
||||||
@ -91,17 +90,11 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitComment = useCallback(
|
const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => {
|
||||||
async (_, values, parentId, resetForm, invoiceHash, invoiceHmac) => {
|
await upsertComment({ variables: { parentId, hash, hmac, ...values } })
|
||||||
const { error } = await upsertComment({ variables: { ...values, parentId, invoiceHash, invoiceHmac } })
|
resetForm({ text: '' })
|
||||||
if (error) {
|
setReply(replyOpen || false)
|
||||||
throw new Error({ message: error.toString() })
|
}, [upsertComment, setReply])
|
||||||
}
|
|
||||||
resetForm({ text: '' })
|
|
||||||
setReply(replyOpen || false)
|
|
||||||
}, [upsertComment, setReply])
|
|
||||||
|
|
||||||
const invoiceableCreateComment = useInvoiceable(submitComment)
|
|
||||||
|
|
||||||
const replyInput = useRef(null)
|
const replyInput = useRef(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -129,9 +122,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
|
|||||||
text: ''
|
text: ''
|
||||||
}}
|
}}
|
||||||
schema={commentSchema}
|
schema={commentSchema}
|
||||||
onSubmit={async ({ cost, ...values }, { resetForm }) => {
|
invoiceable
|
||||||
return invoiceableCreateComment(cost, values, parentId, resetForm)
|
onSubmit={onSubmit}
|
||||||
}}
|
|
||||||
storageKeyPrefix={'reply-' + parentId}
|
storageKeyPrefix={'reply-' + parentId}
|
||||||
>
|
>
|
||||||
<MarkdownInput
|
<MarkdownInput
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import UpBolt from '../svgs/bolt.svg'
|
import UpBolt from '../svgs/bolt.svg'
|
||||||
import styles from './upvote.module.css'
|
import styles from './upvote.module.css'
|
||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import FundError, { isInsufficientFundsError } from './fund-error'
|
import FundError, { payOrLoginError } from './fund-error'
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import ItemAct from './item-act'
|
import ItemAct from './item-act'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
@ -110,8 +110,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||||||
|
|
||||||
const [act] = useMutation(
|
const [act] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation act($id: ID!, $sats: Int!, $invoiceHash: String, $invoiceHmac: String) {
|
mutation act($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
|
||||||
act(id: $id, sats: $sats, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
|
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
|
||||||
sats
|
sats
|
||||||
}
|
}
|
||||||
}`, {
|
}`, {
|
||||||
@ -177,14 +177,14 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isInsufficientFundsError(error)) {
|
if (payOrLoginError(error)) {
|
||||||
showModal(onClose => {
|
showModal(onClose => {
|
||||||
return (
|
return (
|
||||||
<FundError
|
<FundError
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
amount={pendingSats}
|
amount={pendingSats}
|
||||||
onPayment={async (_, invoiceHash) => {
|
onPayment={async ({ hash, hmac }) => {
|
||||||
await act({ variables: { ...variables, invoiceHash } })
|
await act({ variables: { ...variables, hash, hmac } })
|
||||||
strike()
|
strike()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -13,6 +13,7 @@ export const INVOICE = gql`
|
|||||||
confirmedAt
|
confirmedAt
|
||||||
expiresAt
|
expiresAt
|
||||||
nostr
|
nostr
|
||||||
|
isHeld
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
@ -15,7 +15,6 @@ import { ServiceWorkerProvider } from '../components/serviceworker'
|
|||||||
import { SSR } from '../lib/constants'
|
import { SSR } from '../lib/constants'
|
||||||
import NProgress from 'nprogress'
|
import NProgress from 'nprogress'
|
||||||
import 'nprogress/nprogress.css'
|
import 'nprogress/nprogress.css'
|
||||||
import { PaymentTokenProvider } from '../components/payment-tokens'
|
|
||||||
|
|
||||||
NProgress.configure({
|
NProgress.configure({
|
||||||
showSpinner: false
|
showSpinner: false
|
||||||
@ -92,11 +91,9 @@ function MyApp ({ Component, pageProps: { ...props } }) {
|
|||||||
<PriceProvider price={price}>
|
<PriceProvider price={price}>
|
||||||
<LightningProvider>
|
<LightningProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<PaymentTokenProvider>
|
<ShowModalProvider>
|
||||||
<ShowModalProvider>
|
<Component ssrData={ssrData} {...otherProps} />
|
||||||
<Component ssrData={ssrData} {...otherProps} />
|
</ShowModalProvider>
|
||||||
</ShowModalProvider>
|
|
||||||
</PaymentTokenProvider>
|
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</LightningProvider>
|
</LightningProvider>
|
||||||
</PriceProvider>
|
</PriceProvider>
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[preimage]` on the table `Invoice` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Invoice" ADD COLUMN "preimage" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Invoice.preimage_unique" ON "Invoice"("preimage");
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Invoice" ADD COLUMN "isHeld" BOOLEAN;
|
@ -420,6 +420,8 @@ model Invoice {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
userId Int
|
userId Int
|
||||||
hash String @unique(map: "Invoice.hash_unique")
|
hash String @unique(map: "Invoice.hash_unique")
|
||||||
|
preimage String? @unique(map: "Invoice.preimage_unique")
|
||||||
|
isHeld Boolean?
|
||||||
bolt11 String
|
bolt11 String
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
confirmedAt DateTime?
|
confirmedAt DateTime?
|
||||||
|
@ -22,6 +22,9 @@ function nip57 ({ boss, lnd, models }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check if invoice still exists since HODL invoices get deleted after usage
|
||||||
|
if (!inv) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// if parsing fails it's not a zap
|
// if parsing fails it's not a zap
|
||||||
console.log('zapping', inv.desc)
|
console.log('zapping', inv.desc)
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
const serialize = require('../api/resolvers/serial')
|
const serialize = require('../api/resolvers/serial')
|
||||||
const { getInvoice, getPayment } = require('ln-service')
|
const { getInvoice, getPayment, cancelHodlInvoice } = require('ln-service')
|
||||||
const { datePivot } = require('../lib/time')
|
const { datePivot } = require('../lib/time')
|
||||||
|
|
||||||
const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
|
const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
|
||||||
|
|
||||||
// TODO this should all be done via websockets
|
// TODO this should all be done via websockets
|
||||||
function checkInvoice ({ boss, models, lnd }) {
|
function checkInvoice ({ boss, models, lnd }) {
|
||||||
return async function ({ data: { hash } }) {
|
return async function ({ data: { hash, isHeldSet } }) {
|
||||||
let inv
|
let inv
|
||||||
try {
|
try {
|
||||||
inv = await getInvoice({ id: hash, lnd })
|
inv = await getInvoice({ id: hash, lnd })
|
||||||
@ -18,13 +18,20 @@ function checkInvoice ({ boss, models, lnd }) {
|
|||||||
}
|
}
|
||||||
console.log(inv)
|
console.log(inv)
|
||||||
|
|
||||||
|
// check if invoice still exists since HODL invoices get deleted after usage
|
||||||
|
const dbInv = await models.invoice.findUnique({ where: { hash } })
|
||||||
|
if (!dbInv) return
|
||||||
|
|
||||||
|
const expired = new Date(inv.expires_at) <= new Date()
|
||||||
|
|
||||||
if (inv.is_confirmed) {
|
if (inv.is_confirmed) {
|
||||||
await serialize(models,
|
await serialize(models,
|
||||||
models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`)
|
models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`)
|
||||||
await boss.send('nip57', { hash })
|
return boss.send('nip57', { hash })
|
||||||
} else if (inv.is_canceled) {
|
}
|
||||||
// mark as cancelled
|
|
||||||
await serialize(models,
|
if (inv.is_canceled) {
|
||||||
|
return serialize(models,
|
||||||
models.invoice.update({
|
models.invoice.update({
|
||||||
where: {
|
where: {
|
||||||
hash: inv.id
|
hash: inv.id
|
||||||
@ -33,11 +40,27 @@ function checkInvoice ({ boss, models, lnd }) {
|
|||||||
cancelled: true
|
cancelled: true
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
} else if (new Date(inv.expires_at) > new Date()) {
|
}
|
||||||
// not expired, recheck in 5 seconds if the invoice is younger than 5 minutes
|
|
||||||
|
if (inv.is_held && !isHeldSet) {
|
||||||
|
// this is basically confirm_invoice without setting confirmed_at since it's not settled yet
|
||||||
|
// and without setting the user balance since that's done inside the same tx as the HODL invoice action.
|
||||||
|
await serialize(models,
|
||||||
|
models.invoice.update({ where: { hash }, data: { msatsReceived: Number(inv.received_mtokens), isHeld: true } }))
|
||||||
|
// remember that we already executed this if clause
|
||||||
|
// (even though the query above is idempotent but imo, this makes the flow more clear)
|
||||||
|
isHeldSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expired) {
|
||||||
|
// recheck in 5 seconds if the invoice is younger than 5 minutes
|
||||||
// otherwise recheck in 60 seconds
|
// otherwise recheck in 60 seconds
|
||||||
const startAfter = new Date(inv.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60
|
const startAfter = new Date(inv.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60
|
||||||
await boss.send('checkInvoice', { hash }, { ...walletOptions, startAfter })
|
await boss.send('checkInvoice', { hash, isHeldSet }, { ...walletOptions, startAfter })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired && inv.is_held) {
|
||||||
|
await cancelHodlInvoice({ id: hash, lnd })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user