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:
parent
41463d7183
commit
b9461b7eb3
|
@ -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=
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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')) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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'}
|
||||
>
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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])) {
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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'}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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'}
|
||||
>
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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'}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />)}
|
||||
|
|
|
@ -14,6 +14,7 @@ export const COMMENT_FIELDS = gql`
|
|||
id
|
||||
}
|
||||
sats
|
||||
meAnonSats @client
|
||||
upvotes
|
||||
wvotes
|
||||
boost
|
||||
|
|
|
@ -19,6 +19,7 @@ export const ITEM_FIELDS = gql`
|
|||
otsHash
|
||||
position
|
||||
sats
|
||||
meAnonSats @client
|
||||
boost
|
||||
bounty
|
||||
bountyPaidTo
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -5,7 +5,9 @@ export const INVOICE = gql`
|
|||
query Invoice($id: ID!) {
|
||||
invoice(id: $id) {
|
||||
id
|
||||
hash
|
||||
bolt11
|
||||
satsRequested
|
||||
satsReceived
|
||||
cancelled
|
||||
confirmedAt
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
21
lib/time.js
21
lib/time.js
|
@ -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 }
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ export function FundForm () {
|
|||
}, [])
|
||||
|
||||
if (called && !error) {
|
||||
return <QrSkeleton status='generating' />
|
||||
return <QrSkeleton description status='generating' />
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
UPDATE users SET "hideInvoiceDesc" = 't' WHERE id = 27;
|
|
@ -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;
|
||||
$$;
|
|
@ -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;
|
||||
$$;
|
|
@ -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;
|
|
@ -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();
|
|
@ -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%;
|
||||
|
|
|
@ -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 |
|
@ -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}
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue