From 5415c6b0f6133121b32057d4c1347b3e56569b72 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 13 Jul 2023 05:08:32 +0200 Subject: [PATCH] Add anon zaps --- api/resolvers/item.js | 22 ++++- api/resolvers/serial.js | 8 +- api/resolvers/wallet.js | 22 ++--- api/typeDefs/item.js | 2 +- components/invoice.js | 5 +- components/item-act.js | 34 +++++--- components/item-info.js | 8 +- components/upvote.js | 44 +++++----- fragments/comments.js | 1 + fragments/items.js | 1 + lib/anonymous.js | 83 +++++++++++++++++++ lib/apollo.js | 11 +++ lib/constants.js | 2 + pages/invoices/[id].js | 2 +- .../20230719195700_anon_update/migration.sql | 1 + 15 files changed, 190 insertions(+), 56 deletions(-) create mode 100644 lib/anonymous.js create mode 100644 prisma/migrations/20230719195700_anon_update/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index c0aa5bfe..cb20b05a 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -16,6 +16,7 @@ import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, import { sendUserNotification } from '../webPush' import { proxyImages } from './imgproxy' import { defaultCommentSort } from '../../lib/item' +import { checkInvoice } from '../../lib/anonymous' export async function commentFilterClause (me, models) { let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` @@ -711,24 +712,37 @@ export default { return id }, - act: async (parent, { id, sats }, { me, models }) => { + act: async (parent, { id, sats, invoiceId }, { me, models }) => { // need to make sure we are logged in - if (!me) { + if (!me && !invoiceId) { throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) } await ssValidate(amountSchema, { amount: sats }) + let user = me + if (!me && invoiceId) { + const invoice = await checkInvoice(models, invoiceId, sats) + user = invoice.user + } + // disallow self tips const [item] = await models.$queryRawUnsafe(` ${SELECT} FROM "Item" - WHERE id = $1 AND "userId" = $2`, Number(id), me.id) + WHERE id = $1 AND "userId" = $2`, Number(id), user.id) if (item) { throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } }) } - const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`) + const calls = [ + models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)` + ] + if (!me && invoiceId) { + calls.push(models.invoice.delete({ where: { id: Number(invoiceId) } })) + } + + 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'} ${Math.floor(Number(updatedItem.msats) / 1000)} sats${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}` diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js index ec7d6bcb..5edd2c2e 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/wallet.js b/api/resolvers/wallet.js index b4878aa6..08d716bd 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -7,12 +7,9 @@ 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_USER_ID } from '../../lib/constants' 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 +19,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' } }) } @@ -190,13 +196,9 @@ export default { Mutation: { createInvoice: async (parent, { amount }, { me, models, lnd }) => { - if (!me) { - throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) - } - await ssValidate(amountSchema, { amount }) - const user = await models.user.findUnique({ where: { id: me.id } }) + const user = await models.user.findUnique({ where: { id: me ? me.id : ANON_USER_ID } }) // set expires at to 3 hours into future const expiresAt = new Date(new Date().setHours(new Date().getHours() + 3)) @@ -211,7 +213,7 @@ export default { const [inv] = await serialize(models, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, - ${expiresAt}, ${amount * 1000}, ${me.id}::INTEGER, ${description})`) + ${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description})`) return inv } catch (error) { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 60b5e09f..7962d3c8 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -35,7 +35,7 @@ export default gql` createComment(text: String!, parentId: ID!): Item! updateComment(id: ID!, text: String!): Item! dontLikeThis(id: ID!): Boolean! - act(id: ID!, sats: Int): ItemActResult! + act(id: ID!, sats: Int, invoiceId: ID): ItemActResult! pollVote(id: ID!): ID! } diff --git a/components/invoice.js b/components/invoice.js index 5e398714..74fe7de4 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -1,11 +1,12 @@ import Qr from './qr' -export function Invoice ({ invoice }) { +export function Invoice ({ invoice, onConfirmation, successVerb }) { let variant = 'default' let status = 'waiting for you' if (invoice.confirmedAt) { variant = 'confirmed' - status = `${invoice.satsReceived} sats deposited` + status = `${invoice.satsReceived} sats ${successVerb || 'deposited'}` + onConfirmation?.(invoice) } else if (invoice.cancelled) { variant = 'failed' status = 'cancelled' diff --git a/components/item-act.js b/components/item-act.js index 800b8691..77fe0a01 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 { useAnonymous } from '../lib/anonymous' const defaultTips = [100, 1000, 10000, 100000] @@ -45,6 +46,27 @@ export default function ItemAct ({ onClose, itemId, act, strike }) { inputRef.current?.focus() }, [onClose, itemId]) + const submitAct = useCallback( + async (amount, invoiceId) => { + 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), + invoiceId + } + }) + await strike() + addCustomTip(Number(amount)) + onClose() + }, [act, onClose, strike, itemId]) + + const anonAct = useAnonymous(submitAct) + return (
{ - await act({ - variables: { - id: itemId, - sats: Number(amount) - } - }) - await strike() - addCustomTip(Number(amount)) - onClose() + await anonAct(amount) }} > { if (!full) { setHasNewComments(newComments(item)) } }, [item]) + useEffect(() => { + if (item) setMeTotalSats(item.meSats + item.meAnonSats + pendingSats) + }, [item?.meSats, item?.meAnonSats, pendingSats]) + return (
{!item.position && <> - {abbrNum(item.sats + pendingSats)} sats + {abbrNum(item.sats + pendingSats)} sats \ } {item.boost > 0 && diff --git a/components/upvote.js b/components/upvote.js index c5281b1d..0fe7e73a 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -11,7 +11,6 @@ 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' const getColor = (meSats) => { @@ -66,7 +65,6 @@ 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() @@ -110,8 +108,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!, $invoiceId: ID) { + act(id: $id, sats: $sats, invoiceId: $invoiceId) { sats } }`, { @@ -122,17 +120,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 } }) @@ -197,8 +197,9 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt }, [me?.id, item?.fwdUserId, item?.mine, item?.deletedAt]) - const [meSats, sats, overlayText, color] = useMemo(() => { + const [meSats, meTotalSats, sats, overlayText, color] = useMemo(() => { const meSats = (item?.meSats || 0) + pendingSats + const meTotalSats = meSats + (item?.meAnonSats || 0) // what should our next tip be? let sats = me?.tipDefault || 1 @@ -211,8 +212,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } sats = raiseTip - meSats } - return [meSats, sats, `${sats} sat${sats > 1 ? 's' : ''}`, getColor(meSats)] - }, [item?.meSats, pendingSats, me?.tipDefault, me?.turboDefault]) + return [meSats, meTotalSats, sats, `${sats} sat${sats > 1 ? 's' : ''}`, getColor(meTotalSats)] + }, [item?.meSats, item?.meAnonSats, pendingSats, me?.tipDefault, me?.turboDefault]) return ( @@ -251,10 +252,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 => ) } > @@ -268,9 +266,9 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats } `${styles.upvote} ${className || ''} ${disabled ? styles.noSelfTips : ''} - ${meSats ? styles.voted : ''}` + ${meTotalSats ? styles.voted : ''}` } - style={meSats + style={meTotalSats ? { fill: color, filter: `drop-shadow(0 0 6px ${color}90)` 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/lib/anonymous.js b/lib/anonymous.js new file mode 100644 index 00000000..46b0f5ba --- /dev/null +++ b/lib/anonymous.js @@ -0,0 +1,83 @@ +import { useMutation, useQuery } from '@apollo/client' +import { GraphQLError } from 'graphql' +import { gql } from 'graphql-tag' +import { useCallback, useEffect, useState } from 'react' +import { useShowModal } from '../components/modal' +import { Invoice as QrInvoice } from '../components/invoice' +import { QrSkeleton } from '../components/qr' +import { useMe } from '../components/me' +import { msatsToSats } from './format' +import { INVOICE } from '../fragments/wallet' + +const Invoice = ({ id, ...props }) => { + const { data, loading, error } = useQuery(INVOICE, { + pollInterval: 1000, + variables: { id } + }) + if (error) { + console.log(error) + return
error
+ } + if (!data || loading) { + return + } + return +} + +export const useAnonymous = (fn) => { + const me = useMe() + const [createInvoice, { data }] = useMutation(gql` + mutation createInvoice($amount: Int!) { + createInvoice(amount: $amount) { + id + } + }`) + const showModal = useShowModal() + const [fnArgs, setFnArgs] = useState() + + const invoice = data?.createInvoice + useEffect(() => { + if (invoice) { + showModal(onClose => + { + setTimeout(async () => { + await fn(satsReceived, ...fnArgs, id) + onClose() + }, 2000) + } + } successVerb='received' + /> + ) + } + }, [invoice?.id]) + + const anonFn = useCallback((amount, ...args) => { + if (me) return fn(amount, ...args) + setFnArgs(args) + return createInvoice({ variables: { amount } }) + }) + + return anonFn +} + +export const checkInvoice = async (models, invoiceId, fee) => { + const invoice = await models.invoice.findUnique({ + where: { id: Number(invoiceId) }, + 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 +} diff --git a/lib/apollo.js b/lib/apollo.js index 25ba82d5..382ce427 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(localStorage.getItem(`TIP-item:${itemId}`) || '0') + } + } + } } } }), diff --git a/lib/constants.js b/lib/constants.js index 3af84eca..f0bcc8df 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -44,3 +44,5 @@ export const ITEM_TYPES = context => { } export const OLD_ITEM_DAYS = 3 + +export const ANON_USER_ID = 27 diff --git a/pages/invoices/[id].js b/pages/invoices/[id].js index 7ff3cec9..54405bc0 100644 --- a/pages/invoices/[id].js +++ b/pages/invoices/[id].js @@ -5,7 +5,7 @@ import { CenterLayout } from '../../components/layout' import { useRouter } from 'next/router' import { INVOICE } from '../../fragments/wallet' -export default function FullInvoice () { +export default function FullInvoice ({ id }) { const router = useRouter() const { data, error } = useQuery(INVOICE, { pollInterval: 1000, 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;