Add anon zaps
This commit is contained in:
parent
42bdd40f91
commit
5415c6b0f6
@ -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}` : ''}`
|
||||
|
@ -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')) {
|
||||
|
@ -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) {
|
||||
|
@ -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!
|
||||
}
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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 &&
|
||||
|
@ -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 (
|
||||
<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)`
|
||||
|
@ -14,6 +14,7 @@ export const COMMENT_FIELDS = gql`
|
||||
id
|
||||
}
|
||||
sats
|
||||
meAnonSats @client
|
||||
upvotes
|
||||
wvotes
|
||||
boost
|
||||
|
@ -19,6 +19,7 @@ export const ITEM_FIELDS = gql`
|
||||
otsHash
|
||||
position
|
||||
sats
|
||||
meAnonSats @client
|
||||
boost
|
||||
bounty
|
||||
bountyPaidTo
|
||||
|
83
lib/anonymous.js
Normal file
83
lib/anonymous.js
Normal 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
|
||||
}
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
@ -44,3 +44,5 @@ export const ITEM_TYPES = context => {
|
||||
}
|
||||
|
||||
export const OLD_ITEM_DAYS = 3
|
||||
|
||||
export const ANON_USER_ID = 27
|
||||
|
@ -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,
|
||||
|
@ -0,0 +1 @@
|
||||
UPDATE users SET "hideInvoiceDesc" = 't' WHERE id = 27;
|
Loading…
x
Reference in New Issue
Block a user