unify zap/downzap/bounty mutation

This commit is contained in:
keyan 2023-12-26 16:51:47 -06:00
parent 67a9fe23cf
commit 374a7985da
8 changed files with 129 additions and 159 deletions

View File

@ -7,14 +7,14 @@ import { ruleSet as publicationDateRuleSet } from '../../lib/timedate-scraper'
import domino from 'domino'
import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER
} from '../../lib/constants'
import { msatsToSats } from '../../lib/format'
import { parse } from 'tldts'
import uu from 'url-unshort'
import { advSchema, amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
import { sendUserNotification } from '../webPush'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
@ -478,6 +478,10 @@ export default {
${whereClause(
subClause(sub, 3, 'Item', true),
muteClause(me),
'"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".bio = false',
await filterClause(me, models, type))}
ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC
OFFSET $1
@ -748,8 +752,8 @@ export default {
return id
},
act: async (parent, { id, sats, hash, hmac }, { me, models, lnd, headers }) => {
await ssValidate(amountSchema, { amount: sats })
act: async (parent, { id, sats, act = 'TIP', hash, hmac }, { me, models, lnd, headers }) => {
await ssValidate(actSchema, { sats, act })
await assertGofacYourself({ models, headers })
const [item] = await models.$queryRawUnsafe(`
@ -764,9 +768,11 @@ export default {
}
// Disallow tips if me is one of the forward user recipients
const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
if (existingForwards.some(fwd => Number(fwd.userId) === Number(me.id))) {
throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } })
if (act === 'TIP') {
const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
if (existingForwards.some(fwd => Number(fwd.userId) === Number(me.id))) {
throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } })
}
}
}
@ -774,7 +780,7 @@ export default {
models.$queryRaw`
SELECT
item_act(${Number(id)}::INTEGER,
${me?.id || ANON_USER_ID}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`,
${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
{ me, models, lnd, hash, hmac, enforceFee: sats }
)
@ -783,31 +789,9 @@ export default {
return {
id,
sats,
act,
path: item.path
}
},
dontLikeThis: async (parent, { id, sats = DONT_LIKE_THIS_COST, hash, hmac }, { me, lnd, models }) => {
// need to make sure we are logged in
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
// disallow self down votes
const [item] = await models.$queryRawUnsafe(`
${SELECT}
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
if (item) {
throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } })
}
await serializeInvoicable(
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER,
${me.id}::INTEGER, 'DONT_LIKE_THIS', ${sats}::INTEGER)`,
{ me, models, lnd, hash, hmac }
)
return sats
}
},
Item: {

View File

@ -21,6 +21,7 @@ export default gql`
id: ID!
sats: Int!
path: String!
act: String!
}
extend type Mutation {
@ -35,8 +36,7 @@ export default gql`
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
dontLikeThis(id: ID!, sats: Int, hash: String, hmac: String): Int!
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
act(id: ID!, sats: Int, act: String, hash: String, hmac: String): ItemActResult!
pollVote(id: ID!, hash: String, hmac: String): ID!
}

View File

@ -1,4 +1,3 @@
import { gql, useMutation } from '@apollo/client'
import Dropdown from 'react-bootstrap/Dropdown'
import { useShowModal } from './modal'
import { useToast } from './toast'
@ -7,6 +6,7 @@ import AccordianItem from './accordian-item'
import Flag from '../svgs/flag-fill.svg'
import { useMemo } from 'react'
import getColor from '../lib/rainbow'
import { useLightning } from './lightning'
export function DownZap ({ id, meDontLikeSats, ...props }) {
const style = useMemo(() => (meDontLikeSats
@ -23,24 +23,7 @@ export function DownZap ({ id, meDontLikeSats, ...props }) {
function DownZapper ({ id, As, children }) {
const toaster = useToast()
const showModal = useShowModal()
const [dontLikeThis] = useMutation(
gql`
mutation dontLikeThis($id: ID!, $sats: Int, $hash: String, $hmac: String) {
dontLikeThis(id: $id, sats: $sats, hash: $hash, hmac: $hmac)
}`, {
update (cache, { data: { dontLikeThis } }) {
cache.modify({
id: `Item:${id}`,
fields: {
meDontLikeSats (existingSats = 0) {
return existingSats + dontLikeThis
}
}
})
}
}
)
const strike = useLightning()
return (
<As
@ -51,7 +34,7 @@ function DownZapper ({ id, As, children }) {
onClose={() => {
onClose()
toaster.success('item downzapped')
}} itemId={id} act={dontLikeThis} down
}} itemId={id} strike={strike} down
>
<AccordianItem
header='what is a downzap?' body={

View File

@ -5,6 +5,7 @@ import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '../svgs/bolt.svg'
import { amountSchema } from '../lib/validate'
import { gql, useMutation } from '@apollo/client'
const defaultTips = [100, 1000, 10000, 100000]
@ -36,7 +37,7 @@ const addCustomTip = (amount) => {
window.localStorage.setItem('custom-tips', JSON.stringify(customTips))
}
export default function ItemAct ({ onClose, itemId, act, down, strike, children }) {
export default function ItemAct ({ onClose, itemId, down, strike, children }) {
const inputRef = useRef(null)
const me = useMe()
const [oValue, setOValue] = useState()
@ -45,6 +46,8 @@ export default function ItemAct ({ onClose, itemId, act, down, strike, children
inputRef.current?.focus()
}, [onClose, itemId])
const [act] = useAct()
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
if (!me) {
const storageKey = `TIP-item:${itemId}`
@ -55,6 +58,7 @@ export default function ItemAct ({ onClose, itemId, act, down, strike, children
variables: {
id: itemId,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP',
hash,
hmac
}
@ -62,7 +66,7 @@ export default function ItemAct ({ onClose, itemId, act, down, strike, children
strike && await strike()
addCustomTip(Number(amount))
onClose()
}, [act])
}, [act, down, itemId, strike])
return (
<Form
@ -94,3 +98,71 @@ export default function ItemAct ({ onClose, itemId, act, down, strike, children
</Form>
)
}
export function useAct ({ onUpdate } = {}) {
const me = useMe()
const update = useCallback((cache, args) => {
const { data: { act: { id, sats, path, act } } } = args
cache.modify({
id: `Item:${id}`,
fields: {
sats (existingSats = 0) {
if (act === 'TIP') {
return existingSats + sats
}
return existingSats
},
meSats: me
? (existingSats = 0) => {
if (act === 'TIP') {
return existingSats + sats
}
return existingSats
}
: undefined,
meDontLikeSats: me
? (existingSats = 0) => {
if (act === 'DONT_LIKE_THIS') {
return existingSats + sats
}
return existingSats
}
: undefined
}
})
if (act === 'TIP') {
// update all ancestors
path.split('.').forEach(aId => {
if (Number(aId) === Number(id)) return
cache.modify({
id: `Item:${aId}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + sats
}
}
})
})
onUpdate && onUpdate(cache, args)
}
}, [!!me, onUpdate])
return useMutation(
gql`
mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) {
id
sats
path
act
}
}`, { update }
)
}

View File

@ -54,6 +54,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
}
export function ListItem ({ item, ...props }) {
console.log(item)
return (
item.parentId
? <CommentFlat item={item} noReply includeParent clickToContext {...props} />

View File

@ -1,83 +1,51 @@
import React from 'react'
import React, { useCallback } from 'react'
import Button from 'react-bootstrap/Button'
import styles from './pay-bounty.module.css'
import ActionTooltip from './action-tooltip'
import { useMutation, gql } from '@apollo/client'
import { useMe } from './me'
import { numWithUnits } from '../lib/format'
import { useShowModal } from './modal'
import { useRoot } from './root'
import { payOrLoginError, useInvoiceModal } from './invoice'
import { useAct } from './item-act'
export default function PayBounty ({ children, item }) {
const me = useMe()
const showModal = useShowModal()
const root = useRoot()
const [act] = useMutation(
gql`
mutation act($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
sats
const onUpdate = useCallback((cache, { data: { act: { id, path } } }) => {
// update root bounty status
const root = path.split('.')[0]
cache.modify({
id: `Item:${root}`,
fields: {
bountyPaidTo (existingPaidTo = []) {
return [...(existingPaidTo || []), Number(id)]
}
}`, {
update (cache, { data: { act: { sats } } }) {
cache.modify({
id: `Item:${item.id}`,
fields: {
sats (existingSats = 0) {
return existingSats + sats
},
meSats (existingSats = 0) {
return existingSats + sats
}
}
})
// update all ancestor comment sats
item.path.split('.').forEach(id => {
if (Number(id) === Number(item.id)) return
cache.modify({
id: `Item:${id}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + sats
}
}
})
})
// update root bounty status
cache.modify({
id: `Item:${root.id}`,
fields: {
bountyPaidTo (existingPaidTo = []) {
return [...(existingPaidTo || []), Number(item.id)]
}
}
})
}
}
)
})
}, [])
const [act] = useAct({ onUpdate })
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
await act({ variables: { ...variables, hash, hmac } })
}, [act])
const handlePayBounty = async onComplete => {
const variables = { id: item.id, sats: root.bounty, act: 'TIP', path: item.path }
try {
await act({
variables: { id: item.id, sats: root.bounty },
variables,
optimisticResponse: {
act: {
id: `Item:${item.id}`,
sats: root.bounty
}
act: variables
}
})
onComplete()
} catch (error) {
if (payOrLoginError(error)) {
showInvoiceModal({ amount: root.bounty }, { variables: { id: item.id, sats: root.bounty } })
showInvoiceModal({ amount: root.bounty }, { variables })
return
}
throw new Error({ message: error.toString() })

View File

@ -2,7 +2,7 @@ import UpBolt from '../svgs/bolt.svg'
import styles from './upvote.module.css'
import { gql, useMutation } from '@apollo/client'
import ActionTooltip from './action-tooltip'
import ItemAct from './item-act'
import ItemAct, { useAct } from './item-act'
import { useMe } from './me'
import getColor from '../lib/rainbow'
import { useCallback, useMemo, useRef, useState } from 'react'
@ -56,60 +56,15 @@ const TipPopover = ({ target, show, handleClose }) => (
</Overlay>
)
function useAct () {
const me = useMe()
const update = useCallback((cache, { data: { act: { id, sats, path } } }) => {
cache.modify({
id: `Item:${id}`,
fields: {
sats (existingSats = 0) {
return existingSats + sats
},
meSats: me
? (existingSats = 0) => {
return existingSats + sats
}
: undefined
}
})
// update all ancestors
path.split('.').forEach(aId => {
if (Number(aId) === Number(id)) return
cache.modify({
id: `Item:${aId}`,
fields: {
commentSats (existingCommentSats = 0) {
return existingCommentSats + sats
}
}
})
})
}, [!!me])
return useMutation(
gql`
mutation act($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
id
sats
path
}
}`, { update }
)
}
export function DropdownItemUpVote ({ item }) {
const showModal = useShowModal()
const [act] = useAct({ item })
const strike = useLightning()
return (
<Dropdown.Item
onClick={async () => {
showModal(onClose =>
<ItemAct onClose={onClose} itemId={item.id} act={act} />)
<ItemAct onClose={onClose} itemId={item.id} strike={strike} />)
}}
>
<span className='text-success'>zap</span>
@ -145,7 +100,6 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
setWalkthrough({ variables: { upvotePopover: true } })
}
}, [me, voteShow, setWalkthrough])
const setTipShow = useCallback((yes) => {
if (!me) return
@ -161,7 +115,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}
}, [me, tipShow, setWalkthrough])
const [act] = useAct({ item, setVoteShow, setTipShow })
const [act] = useAct()
const showInvoiceModal = useInvoiceModal(
async ({ hash, hmac }, { variables }) => {
@ -171,7 +125,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
const zap = useDebounceCallback(async (sats) => {
if (!sats) return
const variables = { id: item.id, sats }
const variables = { id: item.id, sats, act: 'TIP' }
act({
variables,
@ -179,7 +133,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
act: {
id: item.id,
sats,
path: item.path
path: item.path,
act: 'TIP'
}
}
}).catch((error) => {
@ -229,7 +184,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
setTipShow(false)
showModal(onClose =>
<ItemAct onClose={onClose} itemId={item.id} act={act} strike={strike} />)
<ItemAct onClose={onClose} itemId={item.id} strike={strike} />)
}
}
onShortPress={
@ -244,6 +199,8 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
if (meSats) {
setVoteShow(false)
} else {
setTipShow(true)
}
strike()

View File

@ -341,6 +341,11 @@ export const amountSchema = object({
amount: intValidator.required('required').positive('must be positive')
})
export const actSchema = object({
sats: intValidator.required('required').positive('must be positive'),
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS'])
})
export const settingsSchema = object({
tipDefault: intValidator.required('required').positive('must be positive'),
fiatCurrency: string().required('required').oneOf(SUPPORTED_CURRENCIES),