Add anon zaps

This commit is contained in:
ekzyis 2023-07-13 05:08:32 +02:00
parent 42bdd40f91
commit 5415c6b0f6
15 changed files with 190 additions and 56 deletions

View File

@ -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}` : ''}`

View File

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

View File

@ -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) {

View File

@ -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!
}

View File

@ -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'

View File

@ -1,10 +1,11 @@
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import React, { useState, useRef, useEffect } from 'react'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '../svgs/bolt.svg'
import { amountSchema } from '../lib/validate'
import { 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 (
<Form
initial={{
@ -53,15 +75,7 @@ export default function ItemAct ({ onClose, itemId, act, strike }) {
}}
schema={amountSchema}
onSubmit={async ({ amount }) => {
await act({
variables: {
id: itemId,
sats: Number(amount)
}
})
await strike()
addCustomTip(Number(amount))
onClose()
await anonAct(amount)
}}
>
<Input

View File

@ -24,17 +24,23 @@ export default function ItemInfo ({ item, pendingSats, full, commentsText, class
const [canEdit, setCanEdit] =
useState(item.mine && (Date.now() < editThreshold))
const [hasNewComments, setHasNewComments] = useState(false)
const [meTotalSats, setMeTotalSats] = useState(0)
useEffect(() => {
if (!full) {
setHasNewComments(newComments(item))
}
}, [item])
useEffect(() => {
if (item) setMeTotalSats(item.meSats + item.meAnonSats + pendingSats)
}, [item?.meSats, item?.meAnonSats, pendingSats])
return (
<div className={className || `${styles.other}`}>
{!item.position &&
<>
<span title={`from ${item.upvotes} stackers ${item.mine ? `\\ ${item.meSats} sats to post` : `(${item.meSats + pendingSats} sats from me)`} `}>{abbrNum(item.sats + pendingSats)} sats</span>
<span title={`from ${item.upvotes} stackers ${item.mine ? `\\ ${item.meSats} sats to post` : `(${meTotalSats} sats from me)`} `}>{abbrNum(item.sats + pendingSats)} sats</span>
<span> \ </span>
</>}
{item.boost > 0 &&

View File

@ -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,7 +120,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
sats (existingSats = 0) {
return existingSats + sats
},
meSats (existingSats = 0) {
meSats: me
? (existingSats = 0) => {
if (sats <= me.sats) {
if (existingSats === 0) {
setVoteShow(true)
@ -133,6 +132,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
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 (
<LightningConsumer>
@ -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 => <ItemAct onClose={onClose} itemId={item.id} act={act} strike={strike} />)
}
>
<ActionTooltip notForm disable={disabled} overlayText={overlayText}>
@ -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)`

View File

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

View File

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

83
lib/anonymous.js Normal file
View File

@ -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 <div>error</div>
}
if (!data || loading) {
return <QrSkeleton status='loading' />
}
return <QrInvoice invoice={data.invoice} {...props} />
}
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 =>
<Invoice
id={invoice.id}
onConfirmation={
async ({ id, satsReceived }) => {
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
}

View File

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

View File

@ -44,3 +44,5 @@ export const ITEM_TYPES = context => {
}
export const OLD_ITEM_DAYS = 3
export const ANON_USER_ID = 27

View File

@ -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,

View File

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