From b9461b7eb38f746ec3b14ee2b61f22c631d21b1e Mon Sep 17 00:00:00 2001 From: ekzyis <27162016+ekzyis@users.noreply.github.com> Date: Sat, 12 Aug 2023 01:50:57 +0200 Subject: [PATCH] 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: ) 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 Co-authored-by: keyan --- .env.sample | 1 + api/resolvers/item.js | 132 ++++++++--- api/resolvers/serial.js | 8 +- api/resolvers/user.js | 10 +- api/resolvers/wallet.js | 56 +++-- api/typeDefs/item.js | 10 +- api/typeDefs/wallet.js | 5 +- components/bounty-form.js | 50 +++-- components/discussion-form.js | 41 ++-- components/fee-button.js | 47 +++- components/fee-button.module.css | 9 + components/form.js | 4 +- components/fund-error.js | 23 +- components/{cowboy-hat.js => hat.js} | 22 +- components/header.js | 21 +- components/header.module.css | 16 ++ components/invoice-status.js | 6 +- components/invoice.js | 209 +++++++++++++++++- components/item-act.js | 35 ++- components/item-info.js | 12 +- components/item-job.js | 4 +- components/job-form.js | 63 +++--- components/link-form.js | 40 ++-- components/modal.js | 28 ++- components/poll-form.js | 57 +++-- components/post.js | 20 +- components/qr.js | 10 +- components/reply.js | 28 ++- components/snl.js | 4 +- components/upvote.js | 65 +++--- components/user-header.js | 4 +- components/user-list.js | 4 +- fragments/comments.js | 1 + fragments/items.js | 1 + fragments/users.js | 3 + fragments/wallet.js | 2 + lib/apollo.js | 11 + lib/constants.js | 92 ++++---- lib/item.js | 4 +- lib/time.js | 21 +- pages/api/capture/[[...path]].js | 3 +- pages/api/lnurlp/[username]/pay.js | 9 +- pages/api/lnwith.js | 3 +- pages/invoices/[id].js | 2 +- pages/wallet.js | 2 +- .../20230719195700_anon_update/migration.sql | 1 + .../migration.sql | 36 +++ .../migration.sql | 144 ++++++++++++ .../migration.sql | 21 ++ .../20230811180730_anon_bio/migration.sql | 22 ++ styles/globals.scss | 13 +- svgs/spy-fill.svg | 1 + worker/trust.js | 3 +- worker/wallet.js | 11 +- 54 files changed, 1129 insertions(+), 321 deletions(-) rename components/{cowboy-hat.js => hat.js} (56%) create mode 100644 prisma/migrations/20230719195700_anon_update/migration.sql create mode 100644 prisma/migrations/20230719195700_disable_fee_escalation_for_anons/migration.sql create mode 100644 prisma/migrations/20230810234326_anon_func_exemptions/migration.sql create mode 100644 prisma/migrations/20230811172050_denorm_anon_tips/migration.sql create mode 100644 prisma/migrations/20230811180730_anon_bio/migration.sql create mode 100644 svgs/spy-fill.svg 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 }) } } }