diff --git a/.env.sample b/.env.sample index 16ba3415..02e75587 100644 --- a/.env.sample +++ b/.env.sample @@ -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= diff --git a/api/resolvers/item.js b/api/resolvers/item.js index b49e6de7..d9247810 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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) diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index 46bcc205..9971a3b9 100644 --- a/api/resolvers/serial.js +++ b/api/resolvers/serial.js @@ -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')) { diff --git a/api/resolvers/user.js b/api/resolvers/user.js index c187de9e..87a412be 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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) } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index d005bdd1..e3b82d03 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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: { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 60b5e09f..5852d529 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -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! } diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 068a33c3..6eefe999 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -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 { diff --git a/components/bounty-form.js b/components/bounty-form.js index 8be19d05..ddc66b97 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -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 (
{ - 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'} diff --git a/components/discussion-form.js b/components/discussion-form.js index 3243fd03..590786fd 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -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'} > diff --git a/components/fee-button.js b/components/fee-button.js index bed62826..3ff672d9 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -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 ( + + showModal(onClose => +
You are posting without an account
+
    +
  1. You'll pay by invoice
  2. +
  3. Your content will be content-joined (get it?!) under the @anon account
  4. +
  5. Any sats your content earns will go toward rewards
  6. +
  7. We won't be able to notify you when you receive replies
  8. +
+ btw if you don't need to be anonymous, posting is cheaper with an account +
) + } + /> + ) +} + 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 ( -
+
- {text}{cost > baseFee && show && {numWithUnits(cost, { abbreviate: false })}} + {text}{cost > 1 && show && {numWithUnits(cost, { abbreviate: false })}} + {!me && } {cost > baseFee && show && @@ -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 (
diff --git a/components/fee-button.module.css b/components/fee-button.module.css index c2d40c54..09fdc2dc 100644 --- a/components/fee-button.module.css +++ b/components/fee-button.module.css @@ -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); diff --git a/components/form.js b/components/form.js index 4688f1e2..48f12b84 100644 --- a/components/form.js +++ b/components/form.js @@ -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])) { diff --git a/components/fund-error.js b/components/fund-error.js index e25cddfe..cf0e99c6 100644 --- a/components/fund-error.js +++ b/components/fund-error.js @@ -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 ( <> -

you need more sats

-
+ {error && setError(undefined)} dismissible>{error}} +

you need more sats

+
- + + or +
) } + +export const isInsufficientFundsError = (error) => { + if (Array.isArray(error)) { + return error.some(({ message }) => message.includes('insufficient funds')) + } + return error.toString().includes('insufficient funds') +} diff --git a/components/cowboy-hat.js b/components/hat.js similarity index 56% rename from components/cowboy-hat.js rename to components/hat.js index fda60d27..84aee1f7 100644 --- a/components/cowboy-hat.js +++ b/components/hat.js @@ -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 ( + + {badge + ? ( + + + ) + : } + + ) + } + + 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 ( e.preventDefault()}> - {`@${me.name}`} + {`@${me.name}`} } 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 ( + + post + + ) + } return ( - - post + + post ) } diff --git a/components/header.module.css b/components/header.module.css index a5340c94..6abd5e10 100644 --- a/components/header.module.css +++ b/components/header.module.css @@ -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; diff --git a/components/invoice-status.js b/components/invoice-status.js index bd127208..271599ff 100644 --- a/components/invoice-status.js +++ b/components/invoice-status.js @@ -4,7 +4,7 @@ import ThumbDown from '../svgs/thumb-down-fill.svg' function InvoiceDefaultStatus ({ status }) { return ( -
+
{status}
@@ -13,7 +13,7 @@ function InvoiceDefaultStatus ({ status }) { function InvoiceConfirmedStatus ({ status }) { return ( -
+
{status}
@@ -22,7 +22,7 @@ function InvoiceConfirmedStatus ({ status }) { function InvoiceFailedStatus ({ status }) { return ( -
+
{status}
diff --git a/components/invoice.js b/components/invoice.js index 4c0ddade..8d2a5606 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -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 ( <> - +
{nostr ? ) } + +const Contacts = ({ invoiceHash, invoiceHmac }) => { + const subject = `Support request for payment hash: ${invoiceHash}` + const body = 'Hi, I successfully paid for but the action did not work.' + return ( +
+
+ payment token save this} + type='text' placeholder={invoiceHash + '|' + invoiceHmac} readOnly noForm + /> +
+ +
+ ) +} + +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
error
+ } + if (!data || loading) { + return + } + + 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 ( + <> + + {errorCount > 0 + ? ( + <> +
+ +
+
+ + + ) + : 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 => ( + + ), { keepOpen: true }) + }) + // prevents infinite loop of calling `onConfirmation` + if (errorCount === 0) await repeat() + } + }, [fn, fnArgs] + ) + + const invoice = data?.createInvoice + useEffect(() => { + if (invoice) { + showModal(onClose => ( + + ), { 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 ( + { 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 +} diff --git a/components/item-act.js b/components/item-act.js index 800b8691..1f32bfc3 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -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 ( { - await act({ - variables: { - id: itemId, - sats: Number(amount) - } - }) - await strike() - addCustomTip(Number(amount)) - onClose() + return invoiceableAct(amount) }} > { if (!full) { setHasNewComments(newComments(item)) } }, [item]) + useEffect(() => { + if (item) setMeTotalSats(item.meSats + item.meAnonSats + pendingSats) + }, [item?.meSats, item?.meAnonSats, pendingSats]) + return (
{!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)} @@ -78,7 +84,7 @@ export default function ItemInfo ({ \ - @{item.user.name} + @{item.user.name} {embellishUser} diff --git a/components/item-job.js b/components/item-job.js index b3cbb546..b8a49b75 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -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 }) { \ - @{item.user.name} + @{item.user.name} diff --git a/components/job-form.js b/components/job-form.js index b0884184..c484e0a5 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -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 ( <> { - 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) })} >
diff --git a/components/link-form.js b/components/link-form.js index 3aef521d..f3d3ddb8 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -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'} > diff --git a/components/modal.js b/components/modal.js index 183b762e..5cea3ca8 100644 --- a/components/modal.js +++ b/components/modal.js @@ -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 ( - -
X
+ +
+ {modalStack.length > 0 ?
: null} +
X
+
{modalContent} @@ -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] diff --git a/components/poll-form.js b/components/poll-form.js index 9493e109..7d93cbc1 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -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'} > diff --git a/components/post.js b/components/post.js index 6723a771..73877bbd 100644 --- a/components/post.js +++ b/components/post.js @@ -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 ( -
+
+ {errorMessage && + setErrorMessage(undefined)} dismissible> + {errorMessage} + } {me?.sats < 1 && } @@ -54,11 +68,11 @@ export function PostForm ({ type, sub, children }) { or - +
- +
diff --git a/components/qr.js b/components/qr.js index 772f912a..5a7f866a 100644 --- a/components/qr.js +++ b/components/qr.js @@ -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} /> + {description &&
{description}
}
@@ -36,11 +37,12 @@ export default function Qr ({ asIs, value, webLn, statusVariant, status }) { ) } -export function QrSkeleton ({ status }) { +export function QrSkeleton ({ status, description }) { return ( <> -
-
+
+ {description &&
.
} +
diff --git a/components/reply.js b/components/reply.js index 1eda2eb7..62ba1e2f 100644 --- a/components/reply.js +++ b/components/reply.js @@ -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} > diff --git a/components/snl.js b/components/snl.js index 1df6f210..d382ddf6 100644 --- a/components/snl.js +++ b/components/snl.js @@ -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 } diff --git a/components/upvote.js b/components/upvote.js index 9cc6b83e..13dcd674 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -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 + return ( + { + 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 ( @@ -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 => ) } > diff --git a/components/user-header.js b/components/user-header.js index 8231a0e3..eb35b8cd 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -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 (
-
@{user.name}
+
@{user.name}
{isMe && }
diff --git a/components/user-list.js b/components/user-list.js index ebbbfccc..a805a13a 100644 --- a/components/user-list.js +++ b/components/user-list.js @@ -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 }) => ({abbrNum(user.stacked)} stacked) @@ -72,7 +72,7 @@ export default function UserList ({ ssrData, query, variables, destructureData }
- @{user.name} + @{user.name}
{statComps.map((Comp, i) => )} diff --git a/fragments/comments.js b/fragments/comments.js index b8db60bb..c57cb5fb 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -14,6 +14,7 @@ export const COMMENT_FIELDS = gql` id } sats + meAnonSats @client upvotes wvotes boost diff --git a/fragments/items.js b/fragments/items.js index 0d9c1254..c93e2743 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -19,6 +19,7 @@ export const ITEM_FIELDS = gql` otsHash position sats + meAnonSats @client boost bounty bountyPaidTo diff --git a/fragments/users.js b/fragments/users.js index c6960591..b601db92 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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 diff --git a/fragments/wallet.js b/fragments/wallet.js index 67475c2d..0315ed4f 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -5,7 +5,9 @@ export const INVOICE = gql` query Invoice($id: ID!) { invoice(id: $id) { id + hash bolt11 + satsRequested satsReceived cancelled confirmedAt diff --git a/lib/apollo.js b/lib/apollo.js index 7f0ed314..573d5e8e 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -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') + } + } + } } } }), diff --git a/lib/constants.js b/lib/constants.js index 1a81920d..e2caaa73 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -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' diff --git a/lib/item.js b/lib/item.js index 8ec61e24..2fe868e8 100644 --- a/lib/item.js +++ b/lib/item.js @@ -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' } diff --git a/lib/time.js b/lib/time.js index b1f2e4fe..3573d4b7 100644 --- a/lib/time.js +++ b/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 } diff --git a/pages/api/capture/[[...path]].js b/pages/api/capture/[[...path]].js index 5f71d93d..342b179c 100644 --- a/pages/api/capture/[[...path]].js +++ b/pages/api/capture/[[...path]].js @@ -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() diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 46dcae51..9571b0e4 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -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, diff --git a/pages/api/lnwith.js b/pages/api/lnwith.js index 5dc90cba..afa8185f 100644 --- a/pages/api/lnwith.js +++ b/pages/api/lnwith.js @@ -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 }) } } }) diff --git a/pages/invoices/[id].js b/pages/invoices/[id].js index edd5c3d2..37457efd 100644 --- a/pages/invoices/[id].js +++ b/pages/invoices/[id].js @@ -19,7 +19,7 @@ export default function FullInvoice () { return ( {error &&
{error.toString()}
} - {data ? : } + {data ? : }
) } diff --git a/pages/wallet.js b/pages/wallet.js index 33b81772..0e2c1539 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -92,7 +92,7 @@ export function FundForm () { }, []) if (called && !error) { - return + return } return ( diff --git a/prisma/migrations/20230719195700_anon_update/migration.sql b/prisma/migrations/20230719195700_anon_update/migration.sql new file mode 100644 index 00000000..49cbe800 --- /dev/null +++ b/prisma/migrations/20230719195700_anon_update/migration.sql @@ -0,0 +1 @@ +UPDATE users SET "hideInvoiceDesc" = 't' WHERE id = 27; diff --git a/prisma/migrations/20230719195700_disable_fee_escalation_for_anons/migration.sql b/prisma/migrations/20230719195700_disable_fee_escalation_for_anons/migration.sql new file mode 100644 index 00000000..28992e63 --- /dev/null +++ b/prisma/migrations/20230719195700_disable_fee_escalation_for_anons/migration.sql @@ -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; +$$; \ No newline at end of file diff --git a/prisma/migrations/20230810234326_anon_func_exemptions/migration.sql b/prisma/migrations/20230810234326_anon_func_exemptions/migration.sql new file mode 100644 index 00000000..9df4bd53 --- /dev/null +++ b/prisma/migrations/20230810234326_anon_func_exemptions/migration.sql @@ -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; +$$; \ No newline at end of file diff --git a/prisma/migrations/20230811172050_denorm_anon_tips/migration.sql b/prisma/migrations/20230811172050_denorm_anon_tips/migration.sql new file mode 100644 index 00000000..b9bcd640 --- /dev/null +++ b/prisma/migrations/20230811172050_denorm_anon_tips/migration.sql @@ -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; \ No newline at end of file diff --git a/prisma/migrations/20230811180730_anon_bio/migration.sql b/prisma/migrations/20230811180730_anon_bio/migration.sql new file mode 100644 index 00000000..d3106584 --- /dev/null +++ b/prisma/migrations/20230811180730_anon_bio/migration.sql @@ -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(); diff --git a/styles/globals.scss b/styles/globals.scss index 9650e5f0..00210ddf 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -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%; diff --git a/svgs/spy-fill.svg b/svgs/spy-fill.svg new file mode 100644 index 00000000..eb84073d --- /dev/null +++ b/svgs/spy-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/worker/trust.js b/worker/trust.js index 38b372af..7de7cd96 100644 --- a/worker/trust.js +++ b/worker/trust.js @@ -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} diff --git a/worker/wallet.js b/worker/wallet.js index bcd7c3dc..74576034 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -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 }) } } }