Allow zapping, posting and commenting without funds or an account (#336)

* Add anon zaps

* Add anon comments and posts (link, discussion, poll)

* Use payment hash instead of invoice id as proof of payment

Our invoice IDs can be enumerated.
So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself.
Random payment hashes prevent this.

Also, since we delete invoices after use, using database IDs as proof of payments are not suitable.
If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs.

* Allow pay per invoice for stackers

The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice"

* Fix onSuccess called twice

For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: <Invoice>) is called twice.

This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice.

* Keep invoice modal open if focus is lost

* Skip anon user during trust calculation

* Add error handling

* Skip 'invoice not found' errors

* Remove duplicate insufficient funds handling

* Fix insufficient funds error detection

* Fix invoice amount for comments

* Allow pay per invoice for bounty and job posts

* Also strike on payment after short press

* Fix unexpected token 'export'

* Fix eslint

* Remove unused id param

* Fix comment copy-paste error

* Rename to useInvoiceable

* Fix unexpected token 'export'

* Fix onConfirmation called at every render

* Add invoice HMAC

This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN.

Only the user which created the invoice knows the HMAC and thus can use the invoice hash.

* make anon posting less hidden, add anon info button explainer

* Fix anon users can't zap other anon users

* Always show repeat and contacts on action error

* Keep track of modal stack

* give anon an icon

* add generic date pivot helper

* make anon user's invoices expire in 5 minutes

* fix forgotten find and replace

* use datePivot more places

* add sat amounts to invoices

* reduce anon invoice expiration to 3 minutes

* don't abbreviate

* Fix [object Object] as error message

Any errors thrown here are already objects of shape { message: string }

* Fix empty invoice creation attempts

I stumbled across this while checking if anons can edit their items.

I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error:

  Variable "$amount" of required type "Int!" was not provided.

I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now.

* anon func mods, e.g. inv limits

* anon tips should be denormalized

* remove redundant meTotalSats

* correct overlay zap text for anon

* exclude anon from trust graph before algo runs

* remove balance limit on anon

* give anon a bio and remove cowboy hat/top stackers;

* make anon hat appear on profile

* concat hash and hmac and call it a token

* Fix localStorage cleared because error were swallowed

* fix qr layout shift

* restyle fund error modal

* Catch invoice errors in fund error modal

* invoice check backoff

* anon info typo

* make invoice expiration times have saner defaults

* add comma to anon info

* use builtin copy input label

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
ekzyis 2023-08-12 01:50:57 +02:00 committed by GitHub
parent 41463d7183
commit b9461b7eb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1129 additions and 321 deletions

View File

@ -47,6 +47,7 @@ PUBLIC_URL=http://localhost:3000
LND_CONNECT_ADDRESS=03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcbe78f02@xhlmkj7mfrl6ejnczfwl2vqik3xim6wzmurc2vlyfoqw2sasaocgpuad.onion:9735
NEXTAUTH_SECRET=3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI
JWT_SIGNING_PRIVATE_KEY={"kty":"oct","kid":"FvD__hmeKoKHu2fKjUrWbRKfhjimIM4IKshyrJG4KSM","alg":"HS512","k":"3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"}
INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c91
# imgproxy
NEXT_PUBLIC_IMGPROXY_URL=

View File

@ -7,7 +7,8 @@ import domino from 'domino'
import {
BOOST_MIN, ITEM_SPAM_INTERVAL,
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD,
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE, ANON_ITEM_SPAM_INTERVAL
} from '../../lib/constants'
import { msatsToSats, numWithUnits } from '../../lib/format'
import { parse } from 'tldts'
@ -16,6 +17,7 @@ import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema,
import { sendUserNotification } from '../webPush'
import { proxyImages } from './imgproxy'
import { defaultCommentSort } from '../../lib/item'
import { createHmac } from './wallet'
export async function commentFilterClause (me, models) {
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
@ -36,6 +38,33 @@ export async function commentFilterClause (me, models) {
return clause
}
async function checkInvoice (models, hash, hmac, fee) {
if (!hmac) {
throw new GraphQLError('hmac required', { extensions: { code: 'BAD_INPUT' } })
}
const hmac2 = createHmac(hash)
if (hmac !== hmac2) {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
}
const invoice = await models.invoice.findUnique({
where: { hash },
include: {
user: true
}
})
if (!invoice) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
}
if (!invoice.msatsReceived) {
throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } })
}
if (msatsToSats(invoice.msatsReceived) < fee) {
throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } })
}
return invoice
}
async function comments (me, models, id, sort) {
let orderBy
switch (sort) {
@ -570,7 +599,7 @@ export default {
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac })
}
},
upsertDiscussion: async (parent, args, { me, models }) => {
@ -581,7 +610,7 @@ export default {
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac })
}
},
upsertBounty: async (parent, args, { me, models }) => {
@ -596,8 +625,18 @@ export default {
}
},
upsertPoll: async (parent, { id, ...data }, { me, models }) => {
const { forward, sub, boost, title, text, options } = data
if (!me) {
const { sub, forward, boost, title, text, options, invoiceHash, invoiceHmac } = data
let author = me
let spamInterval = ITEM_SPAM_INTERVAL
const trx = []
if (!me && invoiceHash) {
const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, ANON_POST_FEE)
author = invoice.user
spamInterval = ANON_ITEM_SPAM_INTERVAL
trx.push(models.invoice.delete({ where: { hash: invoiceHash } }))
}
if (!author) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
@ -621,7 +660,7 @@ export default {
if (id) {
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
if (Number(old.userId) !== Number(author.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
}
const [item] = await serialize(models,
@ -632,9 +671,11 @@ export default {
item.comments = []
return item
} else {
const [item] = await serialize(models,
models.$queryRawUnsafe(`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id)))
const [query] = await serialize(models,
models.$queryRawUnsafe(
`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${spamInterval}') AS "Item"`,
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, Number(fwdUser?.id)), ...trx)
const item = trx.length > 0 ? query[0] : query
await createMentions(item, models)
item.comments = []
@ -678,13 +719,14 @@ export default {
},
createComment: async (parent, data, { me, models }) => {
await ssValidate(commentSchema, data)
const item = await createItem(parent, data, { me, models })
const item = await createItem(parent, data,
{ me, models, invoiceHash: data.invoiceHash, invoiceHmac: data.invoiceHmac })
// fetch user to get up-to-date name
const user = await models.user.findUnique({ where: { id: me.id } })
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
const parents = await models.$queryRawUnsafe(
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2',
Number(item.parentId), Number(me.id))
Number(item.parentId), Number(user.id))
Promise.allSettled(
parents.map(({ userId }) => sendUserNotification(userId, {
title: `@${user.name} replied to you`,
@ -711,27 +753,44 @@ export default {
return id
},
act: async (parent, { id, sats }, { me, models }) => {
act: async (parent, { id, sats, invoiceHash, invoiceHmac }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
if (!me && !invoiceHash) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
await ssValidate(amountSchema, { amount: sats })
// disallow self tips
const [item] = await models.$queryRawUnsafe(`
${SELECT}
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
if (item) {
throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } })
let user = me
let invoice
if (!me && invoiceHash) {
invoice = await checkInvoice(models, invoiceHash, invoiceHmac, sats)
user = invoice.user
}
const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`)
// disallow self tips except anons
if (user.id !== ANON_USER_ID) {
const [item] = await models.$queryRawUnsafe(`
${SELECT}
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), user.id)
if (item) {
throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } })
}
}
const calls = [
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
]
if (invoice) {
calls.push(models.invoice.delete({ where: { hash: invoice.hash } }))
}
const [{ item_act: vote }] = await serialize(models, ...calls)
const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${numWithUnits(msatsToSats(updatedItem.msats))}${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}`
const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${
numWithUnits(msatsToSats(updatedItem.msats))}${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}`
sendUserNotification(updatedItem.userId, {
title,
body: updatedItem.title ? updatedItem.title : updatedItem.text,
@ -759,7 +818,8 @@ export default {
throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } })
}
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`)
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER,
${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`)
return true
}
@ -1051,8 +1111,18 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
return item
}
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
if (!me) {
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash, invoiceHmac }) => {
let author = me
let spamInterval = ITEM_SPAM_INTERVAL
const trx = []
if (!me && invoiceHash) {
const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
author = invoice.user
spamInterval = ANON_ITEM_SPAM_INTERVAL
trx.push(models.invoice.delete({ where: { hash: invoiceHash } }))
}
if (!author) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
@ -1075,10 +1145,10 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
url = await proxyImages(url)
text = await proxyImages(text)
const [item] = await serialize(
const [query] = await serialize(
models,
models.$queryRawUnsafe(
`${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
`${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${spamInterval}') AS "Item"`,
parentId ? null : sub || 'bitcoin',
title,
url,
@ -1086,8 +1156,10 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
Number(boost || 0),
bounty ? Number(bounty) : null,
Number(parentId),
Number(me.id),
Number(fwdUser?.id)))
Number(author.id),
Number(fwdUser?.id)),
...trx)
const item = trx.length > 0 ? query[0] : query
await createMentions(item, models)

View File

@ -2,13 +2,13 @@ const { GraphQLError } = require('graphql')
const retry = require('async-retry')
const Prisma = require('@prisma/client')
async function serialize (models, call) {
async function serialize (models, ...calls) {
return await retry(async bail => {
try {
const [, result] = await models.$transaction(
[models.$executeRaw`SELECT ASSERT_SERIALIZED()`, call],
const [, ...result] = await models.$transaction(
[models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...calls],
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
return result
return calls.length > 1 ? result : result[0]
} catch (error) {
console.log(error)
if (error.message.includes('SN_INSUFFICIENT_FUNDS')) {

View File

@ -4,7 +4,7 @@ import { msatsToSats } from '../../lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
import serialize from './serial'
import { dayPivot } from '../../lib/time'
import { datePivot } from '../../lib/time'
export function within (table, within) {
let interval = ' AND "' + table + '".created_at >= $1 - INTERVAL '
@ -54,13 +54,13 @@ export function viewWithin (table, within) {
export function withinDate (within) {
switch (within) {
case 'day':
return dayPivot(new Date(), -1)
return datePivot(new Date(), { days: -1 })
case 'week':
return dayPivot(new Date(), -7)
return datePivot(new Date(), { days: -7 })
case 'month':
return dayPivot(new Date(), -30)
return datePivot(new Date(), { days: -30 })
case 'year':
return dayPivot(new Date(), -365)
return datePivot(new Date(), { days: -365 })
default:
return new Date(0)
}

View File

@ -1,5 +1,6 @@
import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service'
import { GraphQLError } from 'graphql'
import crypto from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import lnpr from 'bolt11'
@ -7,12 +8,10 @@ import { SELECT } from './item'
import { lnurlPayDescriptionHash } from '../../lib/lnurl'
import { msatsToSats, msatsToSatsDecimal } from '../../lib/format'
import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../lib/validate'
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'
export async function getInvoice (parent, { id }, { me, models }) {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const inv = await models.invoice.findUnique({
where: {
id: Number(id)
@ -22,6 +21,15 @@ export async function getInvoice (parent, { id }, { me, models }) {
}
})
if (!inv) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
}
if (inv.user.id === ANON_USER_ID) {
return inv
}
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
if (inv.user.id !== me.id) {
throw new GraphQLError('not ur invoice', { extensions: { code: 'FORBIDDEN' } })
}
@ -34,6 +42,11 @@ export async function getInvoice (parent, { id }, { me, models }) {
return inv
}
export function createHmac (hash) {
const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex')
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
}
export default {
Query: {
invoice: getInvoice,
@ -194,17 +207,23 @@ export default {
},
Mutation: {
createInvoice: async (parent, { amount }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
createInvoice: async (parent, { amount, expireSecs = 3600 }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount })
const user = await models.user.findUnique({ where: { id: me.id } })
let expirePivot = { seconds: expireSecs }
let invLimit = INV_PENDING_LIMIT
let balanceLimit = BALANCE_LIMIT_MSATS
let id = me?.id
if (!me) {
expirePivot = { minutes: 3 }
invLimit = ANON_INV_PENDING_LIMIT
balanceLimit = ANON_BALANCE_LIMIT_MSATS
id = ANON_USER_ID
}
// set expires at to 3 hours into future
const expiresAt = new Date(new Date().setHours(new Date().getHours() + 3))
const user = await models.user.findUnique({ where: { id } })
const expiresAt = datePivot(new Date(), expirePivot)
const description = `Funding @${user.name} on stacker.news`
try {
const invoice = await createInvoice({
@ -216,9 +235,15 @@ export default {
const [inv] = await serialize(models,
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request},
${expiresAt}::timestamp, ${amount * 1000}, ${me.id}::INTEGER, ${description})`)
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description},
${invLimit}::INTEGER, ${balanceLimit})`)
return inv
// the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice
// has access to the HMAC
const hmac = createHmac(inv.hash)
return { ...inv, hmac }
} catch (error) {
console.log(error)
throw error
@ -282,7 +307,8 @@ export default {
},
Invoice: {
satsReceived: i => msatsToSats(i.msatsReceived)
satsReceived: i => msatsToSats(i.msatsReceived),
satsRequested: i => msatsToSats(i.msatsRequested)
},
Fact: {

View File

@ -26,16 +26,16 @@ export default gql`
bookmarkItem(id: ID): Item
subscribeItem(id: ID): Item
deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String): Item!
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int!, boost: Int, forward: String): Item!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String): Item!
createComment(text: String!, parentId: ID!): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: String, invoiceHash: String, invoiceHmac: String): Item!
createComment(text: String!, parentId: ID!, invoiceHash: String, invoiceHmac: String): Item!
updateComment(id: ID!, text: String!): Item!
dontLikeThis(id: ID!): Boolean!
act(id: ID!, sats: Int): ItemActResult!
act(id: ID!, sats: Int, invoiceHash: String, invoiceHmac: String): ItemActResult!
pollVote(id: ID!): ID!
}

View File

@ -9,7 +9,7 @@ export default gql`
}
extend type Mutation {
createInvoice(amount: Int!): Invoice!
createInvoice(amount: Int!, expireSecs: Int): Invoice!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!): Withdrawl!
}
@ -17,12 +17,15 @@ export default gql`
type Invoice {
id: ID!
createdAt: Date!
hash: String!
bolt11: String!
expiresAt: Date!
cancelled: Boolean!
confirmedAt: Date
satsReceived: Int
satsRequested: Int!
nostr: JSONObject
hmac: String
}
type Withdrawl {

View File

@ -8,6 +8,8 @@ import InputGroup from 'react-bootstrap/InputGroup'
import { bountySchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button'
import { useCallback } from 'react'
import { useInvoiceable } from './invoice'
export function BountyForm ({
item,
@ -49,6 +51,32 @@ export function BountyForm ({
`
)
const submitUpsertBounty = useCallback(
// we ignore the invoice since only stackers can post bounties
async (_, boost, bounty, values, ...__) => {
const { error } = await upsertBounty({
variables: {
sub: item?.subName || sub?.name,
id: item?.id,
boost: boost ? Number(boost) : undefined,
bounty: bounty ? Number(bounty) : undefined,
...values
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
}, [upsertBounty, router])
const invoiceableUpsertBounty = useInvoiceable(submitUpsertBounty, { requireSession: true })
return (
<Form
initial={{
@ -61,26 +89,8 @@ export function BountyForm ({
schema={schema}
onSubmit={
handleSubmit ||
(async ({ boost, bounty, ...values }) => {
const { error } = await upsertBounty({
variables: {
sub: item?.subName || sub?.name,
id: item?.id,
boost: boost ? Number(boost) : undefined,
bounty: bounty ? Number(bounty) : undefined,
...values
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
(async ({ boost, bounty, cost, ...values }) => {
return invoiceableUpsertBounty(cost, boost, bounty, values)
})
}
storageKeyPrefix={item ? undefined : 'bounty'}

View File

@ -12,6 +12,8 @@ import Button from 'react-bootstrap/Button'
import { discussionSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button'
import { useCallback } from 'react'
import { useInvoiceable } from './invoice'
export function DiscussionForm ({
item, sub, editThreshold, titleLabel = 'title',
@ -27,13 +29,32 @@ export function DiscussionForm ({
// const me = useMe()
const [upsertDiscussion] = useMutation(
gql`
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward) {
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
id
}
}`
)
const submitUpsertDiscussion = useCallback(
async (_, boost, values, invoiceHash, invoiceHmac) => {
const { error } = await upsertDiscussion({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values, invoiceHash, invoiceHmac }
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
}, [upsertDiscussion, router])
const invoiceableUpsertDiscussion = useInvoiceable(submitUpsertDiscussion)
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
${ITEM_FIELDS}
query related($title: String!) {
@ -57,20 +78,8 @@ export function DiscussionForm ({
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
onSubmit={handleSubmit || (async ({ boost, ...values }) => {
const { error } = await upsertDiscussion({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, ...values }
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
onSubmit={handleSubmit || (async ({ boost, cost, ...values }) => {
return invoiceableUpsertDiscussion(cost, boost, values)
})}
storageKeyPrefix={item ? undefined : 'discussion'}
>

View File

@ -1,11 +1,16 @@
import { useEffect } from 'react'
import Table from 'react-bootstrap/Table'
import ActionTooltip from './action-tooltip'
import Info from './info'
import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client'
import { useFormikContext } from 'formik'
import { SSR } from '../lib/constants'
import { SSR, ANON_COMMENT_FEE, ANON_POST_FEE } from '../lib/constants'
import { numWithUnits } from '../lib/format'
import { useMe } from './me'
import AnonIcon from '../svgs/spy-fill.svg'
import { useShowModal } from './modal'
import Link from 'next/link'
function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
return (
@ -41,22 +46,52 @@ function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
)
}
function AnonInfo () {
const showModal = useShowModal()
return (
<AnonIcon
className='fill-muted ms-2 theme' height={22} width={22}
onClick={
(e) =>
showModal(onClose =>
<div><div className='fw-bold text-center'>You are posting without an account</div>
<ol className='my-3'>
<li>You'll pay by invoice</li>
<li>Your content will be content-joined (get it?!) under the <Link href='/anon' target='_blank'>@anon</Link> account</li>
<li>Any sats your content earns will go toward <Link href='/rewards' target='_blank'>rewards</Link></li>
<li>We won't be able to notify you when you receive replies</li>
</ol>
<small className='text-center fst-italic text-muted'>btw if you don't need to be anonymous, posting is cheaper with an account</small>
</div>)
}
/>
)
}
export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow, disabled }) {
const me = useMe()
baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
const query = parentId
? gql`{ itemRepetition(parentId: "${parentId}") }`
: gql`{ itemRepetition }`
const { data } = useQuery(query, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
const repetition = data?.itemRepetition || 0
const repetition = me ? data?.itemRepetition || 0 : 0
const formik = useFormikContext()
const boost = Number(formik?.values?.boost) || 0
const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost)
useEffect(() => {
formik.setFieldValue('cost', cost)
}, [cost])
const show = alwaysShow || !formik?.isSubmitting
return (
<div className='d-flex align-items-center'>
<div className={styles.feeButton}>
<ActionTooltip overlayText={numWithUnits(cost, { abbreviate: false })}>
<ChildButton variant={variant} disabled={disabled}>{text}{cost > baseFee && show && <small> {numWithUnits(cost, { abbreviate: false })}</small>}</ChildButton>
<ChildButton variant={variant} disabled={disabled}>{text}{cost > 1 && show && <small> {numWithUnits(cost, { abbreviate: false })}</small>}</ChildButton>
</ActionTooltip>
{!me && <AnonInfo />}
{cost > baseFee && show &&
<Info>
<Receipt baseFee={baseFee} hasImgLink={hasImgLink} repetition={repetition} cost={cost} parentId={parentId} boost={boost} />
@ -106,6 +141,10 @@ export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton,
const addImgLink = hasImgLink && !hadImgLink
const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost)
useEffect(() => {
formik.setFieldValue('cost', cost)
}, [cost])
const show = alwaysShow || !formik?.isSubmitting
return (
<div className='d-flex align-items-center'>

View File

@ -6,6 +6,15 @@
width: 100%;
}
.feeButton {
display: flex;
align-items: center;
}
.feeButton small {
font-weight: 400;
}
.receipt td {
padding: .25rem .1rem;
background-color: var(--theme-inputBg);

View File

@ -470,8 +470,8 @@ export function Form ({
initialTouched={validateImmediately && initial}
validateOnBlur={false}
onSubmit={async (values, ...args) =>
onSubmit && onSubmit(values, ...args).then(() => {
if (!storageKeyPrefix) return
onSubmit && onSubmit(values, ...args).then((options) => {
if (!storageKeyPrefix || options?.keepLocalStorage) return
Object.keys(values).forEach(v => {
window.localStorage.removeItem(storageKeyPrefix + '-' + v)
if (Array.isArray(values[v])) {

View File

@ -1,15 +1,30 @@
import Link from 'next/link'
import Button from 'react-bootstrap/Button'
import { useInvoiceable } from './invoice'
import { Alert } from 'react-bootstrap'
import { useState } from 'react'
export default function FundError ({ onClose }) {
export default function FundError ({ onClose, amount, onPayment }) {
const [error, setError] = useState(null)
const createInvoice = useInvoiceable(onPayment, { forceInvoice: true })
return (
<>
<p className='fw-bolder'>you need more sats</p>
<div className='d-flex justify-content-end'>
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}
<p className='fw-bolder text-center'>you need more sats</p>
<div className='d-flex pb-3 pt-2 justify-content-center'>
<Link href='/wallet?type=fund'>
<Button variant='success' onClick={onClose}>fund</Button>
<Button variant='success' onClick={onClose}>fund wallet</Button>
</Link>
<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>
</div>
</>
)
}
export const isInsufficientFundsError = (error) => {
if (Array.isArray(error)) {
return error.some(({ message }) => message.includes('insufficient funds'))
}
return error.toString().includes('insufficient funds')
}

View File

@ -2,10 +2,26 @@ import Badge from 'react-bootstrap/Badge'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
import Tooltip from 'react-bootstrap/Tooltip'
import CowboyHatIcon from '../svgs/cowboy.svg'
import AnonIcon from '../svgs/spy-fill.svg'
import { numWithUnits } from '../lib/format'
import { ANON_USER_ID } from '../lib/constants'
export default function CowboyHat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) {
if (user?.streak === null || user.hideCowboyHat) {
export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) {
if (!user) return null
if (Number(user.id) === ANON_USER_ID) {
return (
<HatTooltip overlayText={badge ? 'anonymous' : 'posted anonymously'}>
{badge
? (
<Badge bg='grey-medium' className='ms-2 d-inline-flex align-items-center'>
<AnonIcon className={`${className} align-middle`} height={height} width={width} />
</Badge>)
: <span><AnonIcon className={`${className} align-middle`} height={height} width={width} /></span>}
</HatTooltip>
)
}
if (user.streak === null || user.hideCowboyHat) {
return null
}
@ -26,7 +42,7 @@ export default function CowboyHat ({ user, badge, className = 'ms-1', height = 1
)
}
function HatTooltip ({ children, overlayText, placement }) {
export function HatTooltip ({ children, overlayText, placement }) {
return (
<OverlayTrigger
placement={placement || 'bottom'}

View File

@ -16,13 +16,14 @@ import { abbrNum } from '../lib/format'
import NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery } from '@apollo/client'
import LightningIcon from '../svgs/bolt.svg'
import CowboyHat from './cowboy-hat'
import { Select } from './form'
import SearchIcon from '../svgs/search-line.svg'
import BackArrow from '../svgs/arrow-left-line.svg'
import { SSR, SUBS } from '../lib/constants'
import { useLightning } from './lightning'
import { HAS_NOTIFICATIONS } from '../fragments/notifications'
import AnonIcon from '../svgs/spy-fill.svg'
import Hat from './hat'
function WalletSummary ({ me }) {
if (!me) return null
@ -83,7 +84,7 @@ function StackerCorner ({ dropNavKey }) {
className={styles.dropdown}
title={
<Nav.Link eventKey={me.name} as='span' className='p-0' onClick={e => e.preventDefault()}>
{`@${me.name}`}<CowboyHat user={me} />
{`@${me.name}`}<Hat user={me} />
</Nav.Link>
}
align='end'
@ -217,11 +218,21 @@ function NavItems ({ className, sub, prefix }) {
function PostItem ({ className, prefix }) {
const me = useMe()
if (!me) return null
if (me) {
return (
<Link href={prefix + '/post'} className={`${className} btn btn-md btn-primary px-3 py-1 `}>
post
</Link>
)
}
return (
<Link href={prefix + '/post'} className={`${className} btn btn-md btn-primary px-3 py-1 `}>
post
<Link
href={prefix + '/post'}
className={`${className} ${styles.postAnon} btn btn-md btn-outline-grey-darkmode d-flex align-items-center px-3 py-0 py-lg-1`}
>
<AnonIcon className='me-1 fill-secondary' width={16} height={16} /> post
</Link>
)
}

View File

@ -9,6 +9,22 @@
color: var(--theme-brandColor) !important;
}
.postAnon {
border-width: 2px;
}
.postAnon svg {
fill: var(--bs-grey-darkmode);
}
.postAnon:hover, .postAnon:active, .postAnon:focus-visible {
color: var(--bs-white) !important;
}
.postAnon:hover svg, .postAnon:active svg, .postAnon:focus-visible svg {
fill: var(--bs-white);
}
.navLinkButton {
border: 2px solid;
padding: 0.2rem .9rem !important;

View File

@ -4,7 +4,7 @@ import ThumbDown from '../svgs/thumb-down-fill.svg'
function InvoiceDefaultStatus ({ status }) {
return (
<div className='d-flex mt-2 justify-content-center'>
<div className='d-flex mt-2 justify-content-center align-items-center'>
<Moon className='spin fill-grey' />
<div className='ms-3 text-muted' style={{ fontWeight: '600' }}>{status}</div>
</div>
@ -13,7 +13,7 @@ function InvoiceDefaultStatus ({ status }) {
function InvoiceConfirmedStatus ({ status }) {
return (
<div className='d-flex mt-2 justify-content-center'>
<div className='d-flex mt-2 justify-content-center align-items-center'>
<Check className='fill-success' />
<div className='ms-3 text-success' style={{ fontWeight: '600' }}>{status}</div>
</div>
@ -22,7 +22,7 @@ function InvoiceConfirmedStatus ({ status }) {
function InvoiceFailedStatus ({ status }) {
return (
<div className='d-flex mt-2 justify-content-center'>
<div className='d-flex mt-2 justify-content-center align-items-center'>
<ThumbDown className='fill-danger' />
<div className='ms-3 text-danger' style={{ fontWeight: '600' }}>{status}</div>
</div>

View File

@ -1,14 +1,25 @@
import AccordianItem from './accordian-item'
import Qr from './qr'
import { useState, useCallback, useEffect } from 'react'
import { useMutation, useQuery } from '@apollo/client'
import { Button } from 'react-bootstrap'
import { gql } from 'graphql-tag'
import { numWithUnits } from '../lib/format'
import AccordianItem from './accordian-item'
import Qr, { QrSkeleton } from './qr'
import { CopyInput } from './form'
import { INVOICE } from '../fragments/wallet'
import InvoiceStatus from './invoice-status'
import { useMe } from './me'
import { useShowModal } from './modal'
import { sleep } from '../lib/time'
import FundError, { isInsufficientFundsError } from './fund-error'
export function Invoice ({ invoice }) {
export function Invoice ({ invoice, onConfirmation, successVerb }) {
let variant = 'default'
let status = 'waiting for you'
let webLn = true
if (invoice.confirmedAt) {
variant = 'confirmed'
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} deposited`
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
webLn = false
} else if (invoice.cancelled) {
variant = 'failed'
@ -20,11 +31,21 @@ export function Invoice ({ invoice }) {
webLn = false
}
useEffect(() => {
if (invoice.confirmedAt) {
onConfirmation?.(invoice)
}
}, [invoice.confirmedAt])
const { nostr } = invoice
return (
<>
<Qr webLn={webLn} value={invoice.bolt11} statusVariant={variant} status={status} />
<Qr
webLn={webLn} value={invoice.bolt11}
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
statusVariant={variant} status={status}
/>
<div className='w-100'>
{nostr
? <AccordianItem
@ -42,3 +63,181 @@ export function Invoice ({ invoice }) {
</>
)
}
const Contacts = ({ invoiceHash, invoiceHmac }) => {
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, {
pollInterval: 1000,
variables: { id }
})
if (error) {
if (error.message?.includes('invoice not found')) {
return
}
return <div>error</div>
}
if (!data || loading) {
return <QrSkeleton description status='loading' />
}
let errorStatus = 'Something went wrong trying to perform the action after payment.'
if (errorCount > 1) {
errorStatus = 'Something still went wrong.\nPlease contact admins for support or to request a refund.'
}
return (
<>
<Invoice invoice={data.invoice} {...props} />
{errorCount > 0
? (
<>
<div className='my-3'>
<InvoiceStatus variant='failed' status={errorStatus} />
</div>
<div className='d-flex flex-row mt-3 justify-content-center'><Button variant='info' onClick={repeat}>Retry</Button></div>
<Contacts invoiceHash={hash} invoiceHmac={hmac} />
</>
)
: null}
</>
)
}
const defaultOptions = {
forceInvoice: false,
requireSession: false
}
export const useInvoiceable = (fn, options = defaultOptions) => {
const me = useMe()
const [createInvoice, { data }] = useMutation(gql`
mutation createInvoice($amount: Int!) {
createInvoice(amount: $amount, expireSecs: 1800) {
id
hash
hmac
}
}`)
const showModal = useShowModal()
const [fnArgs, setFnArgs] = useState()
// fix for bug where `showModal` runs the code for two modals and thus executes `onConfirmation` twice
let errorCount = 0
const onConfirmation = useCallback(
(onClose, hmac) => {
return async ({ id, satsReceived, hash }) => {
await sleep(500)
const repeat = () =>
fn(satsReceived, ...fnArgs, hash, hmac)
.then(onClose)
.catch((error) => {
console.error(error)
errorCount++
onClose()
showModal(onClose => (
<ActionInvoice
id={id}
hash={hash}
hmac={hmac}
onConfirmation={onConfirmation(onClose, hmac)}
successVerb='received'
errorCount={errorCount}
repeat={repeat}
/>
), { keepOpen: true })
})
// prevents infinite loop of calling `onConfirmation`
if (errorCount === 0) await repeat()
}
}, [fn, fnArgs]
)
const invoice = data?.createInvoice
useEffect(() => {
if (invoice) {
showModal(onClose => (
<ActionInvoice
id={invoice.id}
hash={invoice.hash}
hmac={invoice.hmac}
onConfirmation={onConfirmation(onClose, invoice.hmac)}
successVerb='received'
/>
), { keepOpen: true }
)
}
}, [invoice?.id])
const actionFn = useCallback(async (amount, ...args) => {
if (!me && options.requireSession) {
throw new Error('you must be logged in')
}
if (!amount || (me && !options.forceInvoice)) {
try {
return await fn(amount, ...args)
} catch (error) {
if (isInsufficientFundsError(error)) {
showModal(onClose => {
return (
<FundError
onClose={onClose}
amount={amount}
onPayment={async (_, invoiceHash, invoiceHmac) => { await fn(amount, ...args, invoiceHash, invoiceHmac) }}
/>
)
})
return { keepLocalStorage: true }
}
throw error
}
}
setFnArgs(args)
await createInvoice({ variables: { amount } })
// tell onSubmit handler that we want to keep local storage
// even though the submit handler was "successful"
return { keepLocalStorage: true }
}, [fn, setFnArgs, createInvoice])
return actionFn
}

View File

@ -1,10 +1,11 @@
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import React, { useState, useRef, useEffect } from 'react'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '../svgs/bolt.svg'
import { amountSchema } from '../lib/validate'
import { useInvoiceable } from './invoice'
const defaultTips = [100, 1000, 10000, 100000]
@ -45,6 +46,28 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
inputRef.current?.focus()
}, [onClose, itemId])
const submitAct = useCallback(
async (amount, invoiceHash, invoiceHmac) => {
if (!me) {
const storageKey = `TIP-item:${itemId}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
window.localStorage.setItem(storageKey, existingAmount + amount)
}
await act({
variables: {
id: itemId,
sats: Number(amount),
invoiceHash,
invoiceHmac
}
})
await strike()
addCustomTip(Number(amount))
onClose()
}, [act, onClose, strike, itemId])
const invoiceableAct = useInvoiceable(submitAct)
return (
<Form
initial={{
@ -53,15 +76,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
}}
schema={amountSchema}
onSubmit={async ({ amount }) => {
await act({
variables: {
id: itemId,
sats: Number(amount)
}
})
await strike()
addCustomTip(Number(amount))
onClose()
return invoiceableAct(amount)
}}
>
<Input

View File

@ -7,7 +7,6 @@ import Countdown from './countdown'
import { abbrNum, numWithUnits } from '../lib/format'
import { newComments, commentsViewedAt } from '../lib/new-comments'
import { timeSince } from '../lib/time'
import CowboyHat from './cowboy-hat'
import { DeleteDropdownItem } from './delete'
import styles from './item.module.css'
import { useMe } from './me'
@ -16,6 +15,7 @@ import DontLikeThisDropdownItem from './dont-link-this'
import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem } from './share'
import Hat from './hat'
export default function ItemInfo ({
item, pendingSats, full, commentsText = 'comments',
@ -27,12 +27,18 @@ export default function ItemInfo ({
const [canEdit, setCanEdit] =
useState(item.mine && (Date.now() < editThreshold))
const [hasNewComments, setHasNewComments] = useState(false)
const [meTotalSats, setMeTotalSats] = useState(0)
useEffect(() => {
if (!full) {
setHasNewComments(newComments(item))
}
}, [item])
useEffect(() => {
if (item) setMeTotalSats(item.meSats + item.meAnonSats + pendingSats)
}, [item?.meSats, item?.meAnonSats, pendingSats])
return (
<div className={className || `${styles.other}`}>
{!item.position &&
@ -43,7 +49,7 @@ export default function ItemInfo ({
unitPlural: 'stackers'
})} ${item.mine
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
: `(${numWithUnits(item.meSats + pendingSats, { abbreviate: false })} from me)`} `}
: `(${numWithUnits(meTotalSats, { abbreviate: false })} from me)`} `}
>
{numWithUnits(item.sats + pendingSats)}
</span>
@ -78,7 +84,7 @@ export default function ItemInfo ({
<span> \ </span>
<span>
<Link href={`/${item.user.name}`}>
@{item.user.name}<CowboyHat className='ms-1 fill-grey' user={item.user} height={12} width={12} />
@{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} />
{embellishUser}
</Link>
<span> </span>

View File

@ -9,7 +9,7 @@ import Link from 'next/link'
import { timeSince } from '../lib/time'
import EmailIcon from '../svgs/mail-open-line.svg'
import Share from './share'
import CowboyHat from './cowboy-hat'
import Hat from './hat'
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = string().email().isValidSync(item.url)
@ -51,7 +51,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
<span> \ </span>
<span>
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
@{item.user.name}<CowboyHat className='ms-1 fill-grey' user={item.user} height={12} width={12} />
@{item.user.name}<Hat className='ms-1 fill-grey' user={item.user} height={12} width={12} />
</Link>
<span> </span>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>

View File

@ -5,7 +5,7 @@ import InputGroup from 'react-bootstrap/InputGroup'
import Image from 'react-bootstrap/Image'
import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import Info from './info'
import AccordianItem from './accordian-item'
import styles from '../styles/post.module.css'
@ -17,6 +17,7 @@ import Avatar from './avatar'
import ActionTooltip from './action-tooltip'
import { jobSchema } from '../lib/validate'
import CancelButton from './cancel-button'
import { useInvoiceable } from './invoice'
function satsMin2Mo (minute) {
return minute * 30 * 24 * 60
@ -50,6 +51,39 @@ export default function JobForm ({ item, sub }) {
}`
)
const submitUpsertJob = useCallback(
// we ignore the invoice since only stackers can post jobs
async (_, maxBid, stop, start, values, ...__) => {
let status
if (start) {
status = 'ACTIVE'
} else if (stop) {
status = 'STOPPED'
}
const { error } = await upsertJob({
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
maxBid: Number(maxBid),
status,
logo: Number(logoId),
...values
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push(`/~${sub.name}/recent`)
}
}, [upsertJob, router])
const invoiceableUpsertJob = useInvoiceable(submitUpsertJob, { requireSession: true })
return (
<>
<Form
@ -68,32 +102,7 @@ export default function JobForm ({ item, sub }) {
schema={jobSchema}
storageKeyPrefix={storageKeyPrefix}
onSubmit={(async ({ maxBid, stop, start, ...values }) => {
let status
if (start) {
status = 'ACTIVE'
} else if (stop) {
status = 'STOPPED'
}
const { error } = await upsertJob({
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
maxBid: Number(maxBid),
status,
logo: Number(logoId),
...values
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
await router.push(`/~${sub.name}/recent`)
}
return invoiceableUpsertJob(1000, maxBid, stop, start, values)
})}
>
<div className='form-group'>

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { Form, Input, SubmitButton } from '../components/form'
import { useRouter } from 'next/router'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
@ -14,6 +14,7 @@ import { linkSchema } from '../lib/validate'
import Moon from '../svgs/moon-fill.svg'
import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button'
import { useInvoiceable } from './invoice'
export function LinkForm ({ item, sub, editThreshold, children }) {
const router = useRouter()
@ -66,13 +67,31 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const [upsertLink] = useMutation(
gql`
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward) {
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
id
}
}`
)
const submitUpsertLink = useCallback(
async (_, boost, title, values, invoiceHash, invoiceHmac) => {
const { error } = await upsertLink({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), invoiceHash, invoiceHmac, ...values }
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
}, [upsertLink, router])
const invoiceableUpsertLink = useInvoiceable(submitUpsertLink)
useEffect(() => {
if (data?.pageTitleAndUnshorted?.title) {
setTitleOverride(data.pageTitleAndUnshorted.title)
@ -99,19 +118,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
onSubmit={async ({ boost, title, ...values }) => {
const { error } = await upsertLink({
variables: { sub: item?.subName || sub?.name, id: item?.id, boost: boost ? Number(boost) : undefined, title: title.trim(), ...values }
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
onSubmit={async ({ boost, title, cost, ...values }) => {
return invoiceableUpsertLink(cost, boost, title, values)
}}
storageKeyPrefix={item ? undefined : 'link'}
>

View File

@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import BackArrow from '../svgs/arrow-left-line.svg'
export const ShowModalContext = createContext(() => null)
@ -21,9 +22,21 @@ export function useShowModal () {
export default function useModal () {
const [modalContent, setModalContent] = useState(null)
const [modalOptions, setModalOptions] = useState(null)
const [modalStack, setModalStack] = useState([])
const onBack = useCallback(() => {
if (modalStack.length === 0) {
return setModalContent(null)
}
const previousModalContent = modalStack[modalStack.length - 1]
setModalStack(modalStack.slice(0, -1))
return setModalContent(previousModalContent)
}, [modalStack, setModalStack])
const onClose = useCallback(() => {
setModalContent(null)
setModalStack([])
}, [])
const modal = useMemo(() => {
@ -31,8 +44,11 @@ export default function useModal () {
return null
}
return (
<Modal onHide={onClose} show={!!modalContent}>
<div className='modal-close' onClick={onClose}>X</div>
<Modal onHide={modalOptions?.keepOpen ? null : onClose} show={!!modalContent}>
<div className='d-flex flex-row'>
{modalStack.length > 0 ? <div className='modal-btn modal-back' onClick={onBack}><BackArrow width={18} height={18} className='fill-white' /></div> : null}
<div className='modal-btn modal-close' onClick={onClose}>X</div>
</div>
<Modal.Body>
{modalContent}
</Modal.Body>
@ -41,10 +57,14 @@ export default function useModal () {
}, [modalContent, onClose])
const showModal = useCallback(
(getContent) => {
(getContent, options) => {
if (modalContent) {
setModalStack(stack => ([...stack, modalContent]))
}
setModalOptions(options)
setModalContent(getContent(onClose))
},
[onClose]
[modalContent, onClose]
)
return [modal, showModal]

View File

@ -10,6 +10,8 @@ import Button from 'react-bootstrap/Button'
import { pollSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
import CancelButton from './cancel-button'
import { useCallback } from 'react'
import { useInvoiceable } from './invoice'
export function PollForm ({ item, sub, editThreshold, children }) {
const router = useRouter()
@ -19,14 +21,42 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const [upsertPoll] = useMutation(
gql`
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
$options: [String!]!, $boost: Int, $forward: String) {
$options: [String!]!, $boost: Int, $forward: String, $invoiceHash: String, $invoiceHmac: String) {
upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward) {
options: $options, boost: $boost, forward: $forward, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
id
}
}`
)
const submitUpsertPoll = useCallback(
async (_, boost, title, options, values, invoiceHash, invoiceHmac) => {
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { error } = await upsertPoll({
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
boost: boost ? Number(boost) : undefined,
title: title.trim(),
options: optionsFiltered,
...values,
invoiceHash,
invoiceHmac
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
}, [upsertPoll, router])
const invoiceableUpsertPoll = useInvoiceable(submitUpsertPoll)
const initialOptions = item?.poll?.options.map(i => i.option)
return (
@ -39,27 +69,8 @@ export function PollForm ({ item, sub, editThreshold, children }) {
...SubSelectInitial({ sub: item?.subName || sub?.name })
}}
schema={schema}
onSubmit={async ({ boost, title, options, ...values }) => {
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { error } = await upsertPoll({
variables: {
id: item?.id,
sub: item?.subName || sub?.name,
boost: boost ? Number(boost) : undefined,
title: title.trim(),
options: optionsFiltered,
...values
}
})
if (error) {
throw new Error({ message: error.toString() })
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
onSubmit={async ({ boost, title, options, cost, ...values }) => {
return invoiceableUpsertPoll(cost, boost, title, options, values)
}}
storageKeyPrefix={item ? undefined : 'poll'}
>

View File

@ -1,6 +1,7 @@
import JobForm from './job-form'
import Link from 'next/link'
import Button from 'react-bootstrap/Button'
import Alert from 'react-bootstrap/Alert'
import AccordianItem from './accordian-item'
import { useMe } from './me'
import { useRouter } from 'next/router'
@ -10,6 +11,7 @@ import { PollForm } from './poll-form'
import { BountyForm } from './bounty-form'
import SubSelect from './sub-select-form'
import Info from './info'
import { useCallback, useState } from 'react'
function FreebieDialog () {
return (
@ -28,12 +30,24 @@ function FreebieDialog () {
export function PostForm ({ type, sub, children }) {
const me = useMe()
const [errorMessage, setErrorMessage] = useState()
const prefix = sub?.name ? `/~${sub.name}` : ''
const checkSession = useCallback((e) => {
if (!me) {
e.preventDefault()
setErrorMessage('you must be logged in')
}
}, [me, setErrorMessage])
if (!type) {
return (
<div className='align-items-center'>
<div className='position-relative align-items-center'>
{errorMessage &&
<Alert className='position-absolute' style={{ top: '-6rem' }} variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>
{errorMessage}
</Alert>}
{me?.sats < 1 && <FreebieDialog />}
<SubSelect noForm sub={sub?.name} />
<Link href={prefix + '/post?type=link'}>
@ -54,11 +68,11 @@ export function PostForm ({ type, sub, children }) {
</Link>
<span className='mx-3 fw-bold text-muted'>or</span>
<Link href={prefix + '/post?type=bounty'}>
<Button variant='info'>bounty</Button>
<Button onClick={checkSession} variant='info'>bounty</Button>
</Link>
<div className='mt-3 d-flex justify-content-center'>
<Link href='/~jobs/post'>
<Button variant='info'>job</Button>
<Button onClick={checkSession} variant='info'>job</Button>
</Link>
</div>
</div>

View File

@ -4,7 +4,7 @@ import InvoiceStatus from './invoice-status'
import { requestProvider } from 'webln'
import { useEffect } from 'react'
export default function Qr ({ asIs, value, webLn, statusVariant, status }) {
export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) {
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
useEffect(() => {
@ -28,6 +28,7 @@ export default function Qr ({ asIs, value, webLn, statusVariant, status }) {
className='h-auto mw-100' value={qrValue} renderAs='svg' size={300}
/>
</a>
{description && <div className='mt-1 fst-italic text-center text-muted'>{description}</div>}
<div className='mt-3 w-100'>
<CopyInput type='text' placeholder={value} readOnly noForm />
</div>
@ -36,11 +37,12 @@ export default function Qr ({ asIs, value, webLn, statusVariant, status }) {
)
}
export function QrSkeleton ({ status }) {
export function QrSkeleton ({ status, description }) {
return (
<>
<div className='h-auto w-100 clouds' style={{ paddingTop: 'min(300px + 2rem, 100%)', maxWidth: 'calc(300px + 2rem)' }} />
<div className='mt-3 w-100'>
<div className='h-auto mx-auto w-100 clouds' style={{ paddingTop: 'min(300px, 100%)', maxWidth: 'calc(300px)' }} />
{description && <div className='mt-1 fst-italic text-center text-muted invisible'>.</div>}
<div className='my-3 w-100'>
<InputSkeleton />
</div>
<InvoiceStatus variant='default' status={status} />

View File

@ -3,12 +3,13 @@ import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css'
import { COMMENTS } from '../fragments/comments'
import { useMe } from './me'
import { useEffect, useState, useRef } from 'react'
import { useEffect, useState, useRef, useCallback } from 'react'
import Link from 'next/link'
import FeeButton from './fee-button'
import { commentsViewedAfterComment } from '../lib/new-comments'
import { commentSchema } from '../lib/validate'
import Info from './info'
import { useInvoiceable } from './invoice'
export function ReplyOnAnotherPage ({ parentId }) {
return (
@ -45,8 +46,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
const [createComment] = useMutation(
gql`
${COMMENTS}
mutation createComment($text: String!, $parentId: ID!) {
createComment(text: $text, parentId: $parentId) {
mutation createComment($text: String!, $parentId: ID!, $invoiceHash: String, $invoiceHmac: String) {
createComment(text: $text, parentId: $parentId, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
...CommentFields
comments {
...CommentsRecursive
@ -90,6 +91,18 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
}
)
const submitComment = useCallback(
async (_, values, parentId, resetForm, invoiceHash, invoiceHmac) => {
const { error } = await createComment({ variables: { ...values, parentId, invoiceHash, invoiceHmac } })
if (error) {
throw new Error({ message: error.toString() })
}
resetForm({ text: '' })
setReply(replyOpen || false)
}, [createComment, setReply])
const invoiceableCreateComment = useInvoiceable(submitComment)
const replyInput = useRef(null)
useEffect(() => {
if (replyInput.current && reply && !replyOpen) replyInput.current.focus()
@ -116,13 +129,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
text: ''
}}
schema={commentSchema}
onSubmit={async (values, { resetForm }) => {
const { error } = await createComment({ variables: { ...values, parentId } })
if (error) {
throw new Error({ message: error.toString() })
}
resetForm({ text: '' })
setReply(replyOpen || false)
onSubmit={async ({ cost, ...values }, { resetForm }) => {
return invoiceableCreateComment(cost, values, parentId, resetForm)
}}
storageKeyPrefix={'reply-' + parentId}
>

View File

@ -2,7 +2,7 @@ import Alert from 'react-bootstrap/Alert'
import YouTube from '../svgs/youtube-line.svg'
import { useEffect, useState } from 'react'
import { gql, useQuery } from '@apollo/client'
import { dayPivot } from '../lib/time'
import { datePivot } from '../lib/time'
export default function Snl ({ ignorePreference }) {
const [show, setShow] = useState()
@ -12,7 +12,7 @@ export default function Snl ({ ignorePreference }) {
useEffect(() => {
const dismissed = window.localStorage.getItem('snl')
if (!ignorePreference && dismissed && dismissed > new Date(dismissed) < dayPivot(new Date(), -6)) {
if (!ignorePreference && dismissed && dismissed > new Date(dismissed) < datePivot(new Date(), { days: -6 })) {
return
}

View File

@ -1,7 +1,7 @@
import UpBolt from '../svgs/bolt.svg'
import styles from './upvote.module.css'
import { gql, useMutation } from '@apollo/client'
import FundError from './fund-error'
import FundError, { isInsufficientFundsError } from './fund-error'
import ActionTooltip from './action-tooltip'
import ItemAct from './item-act'
import { useMe } from './me'
@ -11,8 +11,7 @@ import LongPressable from 'react-longpressable'
import Overlay from 'react-bootstrap/Overlay'
import Popover from 'react-bootstrap/Popover'
import { useShowModal } from './modal'
import { useRouter } from 'next/router'
import { LightningConsumer } from './lightning'
import { LightningConsumer, useLightning } from './lightning'
import { numWithUnits } from '../lib/format'
const getColor = (meSats) => {
@ -67,12 +66,12 @@ const TipPopover = ({ target, show, handleClose }) => (
export default function UpVote ({ item, className, pendingSats, setPendingSats }) {
const showModal = useShowModal()
const router = useRouter()
const [voteShow, _setVoteShow] = useState(false)
const [tipShow, _setTipShow] = useState(false)
const ref = useRef()
const timerRef = useRef(null)
const me = useMe()
const strike = useLightning()
const [setWalkthrough] = useMutation(
gql`
mutation setWalkthrough($upvotePopover: Boolean, $tipPopover: Boolean) {
@ -111,8 +110,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
const [act] = useMutation(
gql`
mutation act($id: ID!, $sats: Int!) {
act(id: $id, sats: $sats) {
mutation act($id: ID!, $sats: Int!, $invoiceHash: String, $invoiceHmac: String) {
act(id: $id, sats: $sats, invoiceHash: $invoiceHash, invoiceHmac: $invoiceHmac) {
sats
}
}`, {
@ -123,17 +122,19 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
sats (existingSats = 0) {
return existingSats + sats
},
meSats (existingSats = 0) {
if (sats <= me.sats) {
if (existingSats === 0) {
setVoteShow(true)
} else {
setTipShow(true)
}
}
meSats: me
? (existingSats = 0) => {
if (sats <= me.sats) {
if (existingSats === 0) {
setVoteShow(true)
} else {
setTipShow(true)
}
}
return existingSats + sats
}
return existingSats + sats
}
: undefined
}
})
@ -164,10 +165,11 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
if (pendingSats > 0) {
timerRef.current = setTimeout(async (sats) => {
const variables = { id: item.id, sats: pendingSats }
try {
timerRef.current && setPendingSats(0)
await act({
variables: { id: item.id, sats },
variables,
optimisticResponse: {
act: {
sats
@ -175,14 +177,22 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}
})
} catch (error) {
if (!timerRef.current) return
if (error.toString().includes('insufficient funds')) {
if (isInsufficientFundsError(error)) {
showModal(onClose => {
return <FundError onClose={onClose} />
return (
<FundError
onClose={onClose}
amount={pendingSats}
onPayment={async (_, invoiceHash) => {
await act({ variables: { ...variables, invoiceHash } })
strike()
}}
/>
)
})
return
}
if (!timerRef.current) return
throw new Error({ message: error.toString() })
}
}, 500, pendingSats)
@ -199,11 +209,11 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}, [me?.id, item?.fwdUserId, item?.mine, item?.deletedAt])
const [meSats, sats, overlayText, color] = useMemo(() => {
const meSats = (item?.meSats || 0) + pendingSats
const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats
// what should our next tip be?
let sats = me?.tipDefault || 1
if (me?.turboTipping && me) {
if (me?.turboTipping) {
let raiseTip = sats
while (meSats >= raiseTip) {
raiseTip *= 10
@ -212,8 +222,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
sats = raiseTip - meSats
}
return [meSats, sats, numWithUnits(sats, { abbreviate: false }), getColor(meSats)]
}, [item?.meSats, pendingSats, me?.tipDefault, me?.turboDefault])
return [meSats, sats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it', getColor(meSats)]
}, [item?.meSats, item?.meAnonSats, pendingSats, me?.tipDefault, me?.turboDefault])
return (
<LightningConsumer>
@ -252,10 +262,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
setPendingSats(pendingSats + sats)
}
: async () => await router.push({
pathname: '/signup',
query: { callbackUrl: window.location.origin + router.asPath }
})
: () => showModal(onClose => <ItemAct onClose={onClose} itemId={item.id} act={act} strike={strike} />)
}
>
<ActionTooltip notForm disable={disabled} overlayText={overlayText}>

View File

@ -14,10 +14,10 @@ import QRCode from 'qrcode.react'
import LightningIcon from '../svgs/bolt.svg'
import { encodeLNUrl } from '../lib/lnurl'
import Avatar from './avatar'
import CowboyHat from './cowboy-hat'
import { userSchema } from '../lib/validate'
import { useShowModal } from './modal'
import { numWithUnits } from '../lib/format'
import Hat from './hat'
export default function UserHeader ({ user }) {
const router = useRouter()
@ -149,7 +149,7 @@ function NymEdit ({ user, setEditting }) {
function NymView ({ user, isMe, setEditting }) {
return (
<div className='d-flex align-items-center mb-2'>
<div className={styles.username}>@{user.name}<CowboyHat className='' user={user} badge /></div>
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
{isMe &&
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
</div>

View File

@ -1,13 +1,13 @@
import Link from 'next/link'
import Image from 'react-bootstrap/Image'
import { abbrNum, numWithUnits } from '../lib/format'
import CowboyHat from './cowboy-hat'
import styles from './item.module.css'
import userStyles from './user-header.module.css'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@apollo/client'
import MoreFooter from './more-footer'
import { useData } from './use-data'
import Hat from './hat'
// all of this nonsense is to show the stat we are sorting by first
const Stacked = ({ user }) => (<span>{abbrNum(user.stacked)} stacked</span>)
@ -72,7 +72,7 @@ export default function UserList ({ ssrData, query, variables, destructureData }
</Link>
<div className={styles.hunk}>
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
@{user.name}<CowboyHat className='ms-1 fill-grey' height={14} width={14} user={user} />
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
</Link>
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}

View File

@ -14,6 +14,7 @@ export const COMMENT_FIELDS = gql`
id
}
sats
meAnonSats @client
upvotes
wvotes
boost

View File

@ -19,6 +19,7 @@ export const ITEM_FIELDS = gql`
otsHash
position
sats
meAnonSats @client
boost
bounty
bountyPaidTo

View File

@ -110,6 +110,7 @@ export const USER_SEARCH =
gql`
query searchUsers($q: String!, $limit: Int, $similarity: Float) {
searchUsers(q: $q, limit: $limit, similarity: $similarity) {
id
name
streak
hideCowboyHat
@ -139,6 +140,7 @@ export const TOP_USERS = gql`
query TopUsers($cursor: String, $when: String, $by: String) {
topUsers(cursor: $cursor, when: $when, by: $by) {
users {
id
name
streak
hideCowboyHat
@ -158,6 +160,7 @@ export const TOP_COWBOYS = gql`
query TopCowboys($cursor: String) {
topCowboys(cursor: $cursor) {
users {
id
name
streak
hideCowboyHat

View File

@ -5,7 +5,9 @@ export const INVOICE = gql`
query Invoice($id: ID!) {
invoice(id: $id) {
id
hash
bolt11
satsRequested
satsReceived
cancelled
confirmedAt

View File

@ -141,6 +141,17 @@ function getClient (uri) {
}
}
}
},
Item: {
fields: {
meAnonSats: {
read (meAnonSats, { readField }) {
if (typeof window === 'undefined') return null
const itemId = readField('id')
return meAnonSats ?? Number(window.localStorage.getItem(`TIP-item:${itemId}`) || '0')
}
}
}
}
}
}),

View File

@ -1,47 +1,53 @@
export const NOFOLLOW_LIMIT = 100
export const BOOST_MIN = 5000
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
export const IMAGE_PIXELS_MAX = 35000000
export const UPLOAD_TYPES_ALLOW = [
'image/gif',
'image/heic',
'image/png',
'image/jpeg',
'image/webp'
]
export const COMMENT_DEPTH_LIMIT = 10
export const MAX_TITLE_LENGTH = 80
export const MAX_POLL_CHOICE_LENGTH = 30
export const ITEM_SPAM_INTERVAL = '10m'
export const MAX_POLL_NUM_CHOICES = 10
export const MIN_POLL_NUM_CHOICES = 2
export const ITEM_FILTER_THRESHOLD = 1.2
export const DONT_LIKE_THIS_COST = 1
export const COMMENT_TYPE_QUERY = ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks']
// XXX this is temporary until we have so many subs they have
// to be loaded from the server
export const SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
export const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs')
export const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals']
export const ITEM_SORTS = ['votes', 'comments', 'sats']
export const WHENS = ['day', 'week', 'month', 'year', 'forever']
const SUBS = ['bitcoin', 'nostr', 'tech', 'meta', 'jobs']
const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs')
export const ITEM_TYPES = context => {
if (context === 'jobs') {
return ['posts', 'comments', 'all', 'freebies']
}
const items = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls']
if (!context) {
items.push('bios', 'jobs')
}
items.push('freebies')
if (context === 'user') {
items.push('jobs', 'bookmarks')
}
return items
module.exports = {
NOFOLLOW_LIMIT: 100,
BOOST_MIN: 5000,
UPLOAD_SIZE_MAX: 2 * 1024 * 1024,
IMAGE_PIXELS_MAX: 35000000,
UPLOAD_TYPES_ALLOW: [
'image/gif',
'image/heic',
'image/png',
'image/jpeg',
'image/webp'
],
COMMENT_DEPTH_LIMIT: 10,
MAX_TITLE_LENGTH: 80,
MAX_POLL_CHOICE_LENGTH: 30,
ITEM_SPAM_INTERVAL: '10m',
ANON_ITEM_SPAM_INTERVAL: '0',
INV_PENDING_LIMIT: 10,
BALANCE_LIMIT_MSATS: 1000000000, // 1m sats
ANON_INV_PENDING_LIMIT: 100,
ANON_BALANCE_LIMIT_MSATS: 0, // disable
MAX_POLL_NUM_CHOICES: 10,
MIN_POLL_NUM_CHOICES: 2,
ITEM_FILTER_THRESHOLD: 1.2,
DONT_LIKE_THIS_COST: 1,
COMMENT_TYPE_QUERY: ['comments', 'freebies', 'outlawed', 'borderland', 'all', 'bookmarks'],
SUBS,
SUBS_NO_JOBS,
USER_SORTS: ['stacked', 'spent', 'comments', 'posts', 'referrals'],
ITEM_SORTS: ['votes', 'comments', 'sats'],
WHENS: ['day', 'week', 'month', 'year', 'forever'],
ITEM_TYPES: context => {
const items = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls']
if (!context) {
items.push('bios', 'jobs')
}
items.push('freebies')
if (context === 'user') {
items.push('jobs', 'bookmarks')
}
return items
},
OLD_ITEM_DAYS: 3,
ANON_USER_ID: 27,
ANON_POST_FEE: 1000,
ANON_COMMENT_FEE: 100,
SSR: typeof window === 'undefined'
}
export const OLD_ITEM_DAYS = 3
export const SSR = typeof window === 'undefined'

View File

@ -1,11 +1,11 @@
import { OLD_ITEM_DAYS } from './constants'
import { dayPivot } from './time'
import { datePivot } from './time'
export const defaultCommentSort = (pinned, bio, createdAt) => {
// pins sort by recent
if (pinned) return 'recent'
// old items (that aren't bios) sort by top
if (!bio && new Date(createdAt) < dayPivot(new Date(), -OLD_ITEM_DAYS)) return 'top'
if (!bio && new Date(createdAt) < datePivot(new Date(), { days: -OLD_ITEM_DAYS })) return 'top'
// everything else sorts by hot
return 'hot'
}

View File

@ -1,4 +1,4 @@
export function timeSince (timeStamp) {
function timeSince (timeStamp) {
const now = new Date()
const secondsPast = (now.getTime() - timeStamp) / 1000
if (secondsPast < 60) {
@ -20,11 +20,20 @@ export function timeSince (timeStamp) {
return 'now'
}
export function dayPivot (date, days) {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
function datePivot (date,
{ years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) {
return new Date(
date.getFullYear() + years,
date.getMonth() + months,
date.getDate() + days,
date.getHours() + hours,
date.getMinutes() + minutes,
date.getSeconds() + seconds,
date.getMilliseconds() + milliseconds
)
}
export function timeLeft (timeStamp) {
function timeLeft (timeStamp) {
const now = new Date()
const secondsPast = (timeStamp - now.getTime()) / 1000
@ -45,3 +54,7 @@ export function timeLeft (timeStamp) {
return parseInt(secondsPast / (3600 * 24)) + ' days'
}
}
const sleep = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms))
module.exports = { timeSince, datePivot, timeLeft, sleep }

View File

@ -1,6 +1,7 @@
import path from 'path'
import AWS from 'aws-sdk'
import { PassThrough } from 'stream'
import { datePivot } from '../../../lib/time'
const { spawn } = require('child_process')
const encodeS3URI = require('node-s3-url-encode')
@ -28,7 +29,7 @@ export default async function handler (req, res) {
aws.headObject({
Bucket: bucketName,
Key: s3PathPUT,
IfModifiedSince: new Date(new Date().getTime() - 15 * 60000)
IfModifiedSince: datePivot(new Date(), { minutes: -15 })
}).promise().then(() => {
// this path is cached so return it
res.writeHead(302, { Location: bucketUrl + s3PathGET }).end()

View File

@ -5,6 +5,8 @@ import { lnurlPayDescriptionHashForUser } from '../../../../lib/lnurl'
import serialize from '../../../../api/resolvers/serial'
import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto'
import { datePivot } from '../../../../lib/time'
import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT } from '../../../../lib/constants'
export default async ({ query: { username, amount, nostr } }, res) => {
const user = await models.user.findUnique({ where: { name: username } })
@ -12,7 +14,7 @@ export default async ({ query: { username, amount, nostr } }, res) => {
return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` })
}
try {
// if nostr, decode, validate sig, check tags, set description hash
// if nostr, decode, validate sig, check tags, set description hash
let description, descriptionHash, noteStr
if (nostr) {
noteStr = decodeURIComponent(nostr)
@ -36,7 +38,7 @@ export default async ({ query: { username, amount, nostr } }, res) => {
}
// generate invoice
const expiresAt = new Date(new Date().setMinutes(new Date().getMinutes() + 1))
const expiresAt = datePivot(new Date(), { minutes: 1 })
const invoice = await createInvoice({
description,
description_hash: descriptionHash,
@ -47,7 +49,8 @@ export default async ({ query: { username, amount, nostr } }, res) => {
await serialize(models,
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request},
${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description})`)
${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description},
${INV_PENDING_LIMIT}::INTEGER, ${BALANCE_LIMIT_MSATS})`)
return res.status(200).json({
pr: invoice.request,

View File

@ -3,6 +3,7 @@
import models from '../../api/models'
import getSSRApolloClient from '../../api/ssrApollo'
import { CREATE_WITHDRAWL } from '../../fragments/wallet'
import { datePivot } from '../../lib/time'
export default async ({ query }, res) => {
if (!query.k1) {
@ -19,7 +20,7 @@ export default async ({ query }, res) => {
where: {
k1: query.k1,
createdAt: {
gt: new Date(new Date().setHours(new Date().getHours() - 1))
gt: datePivot(new Date(), { hours: -1 })
}
}
})

View File

@ -19,7 +19,7 @@ export default function FullInvoice () {
return (
<CenterLayout>
{error && <div>{error.toString()}</div>}
{data ? <Invoice invoice={data.invoice} /> : <QrSkeleton status='loading' />}
{data ? <Invoice invoice={data.invoice} /> : <QrSkeleton description status='loading' />}
</CenterLayout>
)
}

View File

@ -92,7 +92,7 @@ export function FundForm () {
}, [])
if (called && !error) {
return <QrSkeleton status='generating' />
return <QrSkeleton description status='generating' />
}
return (

View File

@ -0,0 +1 @@
UPDATE users SET "hideInvoiceDesc" = 't' WHERE id = 27;

View File

@ -0,0 +1,36 @@
CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
repeats INTEGER;
self_replies INTEGER;
BEGIN
IF user_id = 27 THEN
-- disable fee escalation for anon user
RETURN 0;
END IF;
SELECT count(*) INTO repeats
FROM "Item"
WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id)
AND "userId" = user_id
AND created_at > now_utc() - within;
IF parent_id IS NULL THEN
RETURN repeats;
END IF;
WITH RECURSIVE base AS (
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM "Item"
WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within
UNION ALL
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM base p
JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within)
SELECT count(*) INTO self_replies FROM base;
RETURN repeats + self_replies;
END;
$$;

View File

@ -0,0 +1,144 @@
DROP FUNCTION IF EXISTS create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, msats_req BIGINT, user_id INTEGER, idesc TEXT);
-- make invoice limit and balance limit configurable
CREATE OR REPLACE FUNCTION create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone,
msats_req BIGINT, user_id INTEGER, idesc TEXT, inv_limit INTEGER, balance_limit_msats BIGINT)
RETURNS "Invoice"
LANGUAGE plpgsql
AS $$
DECLARE
invoice "Invoice";
inv_limit_reached BOOLEAN;
balance_limit_reached BOOLEAN;
inv_pending_msats BIGINT;
BEGIN
PERFORM ASSERT_SERIALIZED();
-- prevent too many pending invoices
SELECT inv_limit > 0 AND count(*) >= inv_limit, sum("msatsRequested") INTO inv_limit_reached, inv_pending_msats
FROM "Invoice"
WHERE "userId" = user_id AND "expiresAt" > now_utc() AND "confirmedAt" IS NULL AND cancelled = false;
IF inv_limit_reached THEN
RAISE EXCEPTION 'SN_INV_PENDING_LIMIT';
END IF;
-- prevent pending invoices + msats from exceeding the limit
SELECT balance_limit_msats > 0 AND inv_pending_msats+msats_req+msats > balance_limit_msats INTO balance_limit_reached
FROM users
WHERE id = user_id;
IF balance_limit_reached THEN
RAISE EXCEPTION 'SN_INV_EXCEED_BALANCE';
END IF;
-- we good, proceed frens
INSERT INTO "Invoice" (hash, bolt11, "expiresAt", "msatsRequested", "userId", created_at, updated_at, "desc")
VALUES (hash, bolt11, expires_at, msats_req, user_id, now_utc(), now_utc(), idesc) RETURNING * INTO invoice;
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('checkInvoice', jsonb_build_object('hash', hash), 21, true, now() + interval '10 seconds');
RETURN invoice;
END;
$$;
-- don't presume outlaw status for anon posters
CREATE OR REPLACE FUNCTION create_item(
sub TEXT, title TEXT, url TEXT, text TEXT, boost INTEGER, bounty INTEGER,
parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER,
spam_within INTERVAL)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
cost_msats BIGINT;
freebie BOOLEAN;
item "Item";
med_votes FLOAT;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats INTO user_msats FROM users WHERE id = user_id;
cost_msats := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within));
-- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0
freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (boost = 0);
IF NOT freebie AND cost_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- get this user's median item score
SELECT COALESCE(percentile_cont(0.5) WITHIN GROUP(ORDER BY "weightedVotes" - "weightedDownVotes"), 0)
INTO med_votes FROM "Item" WHERE "userId" = user_id;
-- if their median votes are positive, start at 0
-- if the median votes are negative, start their post with that many down votes
-- basically: if their median post is bad, presume this post is too
-- addendum: if they're an anon poster, always start at 0
IF med_votes >= 0 OR user_id = 27 THEN
med_votes := 0;
ELSE
med_votes := ABS(med_votes);
END IF;
INSERT INTO "Item"
("subName", title, url, text, bounty, "userId", "parentId", "fwdUserId",
freebie, "weightedDownVotes", created_at, updated_at)
VALUES
(sub, title, url, text, bounty, user_id, parent_id, fwd_user_id,
freebie, med_votes, now_utc(), now_utc()) RETURNING * INTO item;
IF NOT freebie THEN
UPDATE users SET msats = msats - cost_msats WHERE id = user_id;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (cost_msats, item.id, user_id, 'FEE', now_utc(), now_utc());
END IF;
IF boost > 0 THEN
PERFORM item_act(item.id, user_id, 'BOOST', boost);
END IF;
RETURN item;
END;
$$;
-- keep item_spam unaware of anon user
CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
repeats INTEGER;
self_replies INTEGER;
BEGIN
-- no fee escalation
IF within = interval '0' THEN
RETURN 0;
END IF;
SELECT count(*) INTO repeats
FROM "Item"
WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id)
AND "userId" = user_id
AND created_at > now_utc() - within;
IF parent_id IS NULL THEN
RETURN repeats;
END IF;
WITH RECURSIVE base AS (
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM "Item"
WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within
UNION ALL
SELECT "Item".id, "Item"."parentId", "Item"."userId"
FROM base p
JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within)
SELECT count(*) INTO self_replies FROM base;
RETURN repeats + self_replies;
END;
$$;

View File

@ -0,0 +1,21 @@
-- make excaption for anon users
CREATE OR REPLACE FUNCTION sats_after_tip(item_id INTEGER, user_id INTEGER, tip_msats BIGINT) RETURNS INTEGER AS $$
DECLARE
item "Item";
BEGIN
SELECT * FROM "Item" WHERE id = item_id INTO item;
IF user_id <> 27 AND item."userId" = user_id THEN
RETURN 0;
END IF;
UPDATE "Item"
SET "msats" = "msats" + tip_msats
WHERE id = item.id;
UPDATE "Item"
SET "commentMsats" = "commentMsats" + tip_msats
WHERE id <> item.id and path @> item.path;
RETURN 1;
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,22 @@
set transaction isolation level serializable;
-- hack ... prisma doesn't know about our other schemas (e.g. pgboss)
-- and this is only really a problem on their "shadow database"
-- so we catch the exception it throws and ignore it
CREATE OR REPLACE FUNCTION create_anon_bio()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
-- give anon a bio
PERFORM create_bio('@anon''s bio', 'account of stackers just passing through', 27);
-- hide anon from top users and dont give them a hat
UPDATE users set "hideFromTopUsers" = true, "hideCowboyHat" = true where id = 27;
return 0;
EXCEPTION WHEN sqlstate '42P01' THEN
return 0;
END;
$$;
SELECT create_anon_bio();
DROP FUNCTION IF EXISTS create_anon_bio();

View File

@ -175,11 +175,20 @@ $grid-gutter-width: 2rem;
margin-bottom: 0 !important;
}
.modal-close {
.modal-btn {
cursor: pointer;
display: flex;
margin-left: auto;
padding-top: 1rem;
align-items: center;
}
.modal-back {
margin-right: auto;
padding-left: 1.5rem;
}
.modal-close {
margin-left: auto;
padding-right: 1.5rem;
font-family: "lightning";
font-size: 150%;

1
svgs/spy-fill.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17 13C19.2091 13 21 14.7909 21 17C21 19.2091 19.2091 21 17 21C14.8578 21 13 19.21 13 17H11C11 19.2091 9.20914 21 7 21C4.79086 21 3 19.2091 3 17C3 14.7909 4.79086 13 7 13C8.48052 13 9.77317 13.8043 10.4648 14.9999H13.5352C14.2268 13.8043 15.5195 13 17 13ZM2 12V10H4V7C4 4.79086 5.79086 3 8 3H16C18.2091 3 20 4.79086 20 7V10H22V12H2Z"></path></svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -1,4 +1,5 @@
const math = require('mathjs')
const { ANON_USER_ID } = require('../lib/constants')
function trust ({ boss, models }) {
return async function () {
@ -118,7 +119,7 @@ async function getGraph (models) {
FROM "ItemAct"
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
JOIN users ON "ItemAct"."userId" = users.id
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${ANON_USER_ID}
GROUP BY user_id, name, item_id, user_at, against
HAVING CASE WHEN
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}

View File

@ -1,8 +1,10 @@
const serialize = require('../api/resolvers/serial')
const { getInvoice, getPayment } = require('ln-service')
const { datePivot } = require('../lib/time')
const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true }
// TODO this should all be done via websockets
function checkInvoice ({ boss, models, lnd }) {
return async function ({ data: { hash } }) {
let inv
@ -32,8 +34,10 @@ function checkInvoice ({ boss, models, lnd }) {
}
}))
} else if (new Date(inv.expires_at) > new Date()) {
// not expired, recheck in 5 seconds
await boss.send('checkInvoice', { hash }, walletOptions)
// not expired, recheck in 5 seconds if the invoice is younger than 5 minutes
// otherwise recheck in 60 seconds
const startAfter = new Date(inv.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60
await boss.send('checkInvoice', { hash }, { ...walletOptions, startAfter })
}
}
}
@ -76,7 +80,8 @@ function checkWithdrawal ({ boss, models, lnd }) {
SELECT reverse_withdrawl(${id}::INTEGER, ${status}::"WithdrawlStatus")`)
} else {
// we need to requeue to check again in 5 seconds
await boss.send('checkWithdrawal', { id, hash }, walletOptions)
const startAfter = new Date(wdrwl.created_at) > datePivot(new Date(), { minutes: -5 }) ? 5 : 60
await boss.send('checkWithdrawal', { id, hash }, { ...walletOptions, startAfter })
}
}
}