Merge pull request #545 from SatsAllDay/lud18-fixes

LUD-18 fixes
This commit is contained in:
Keyan 2023-10-06 15:15:33 -05:00 committed by GitHub
commit 33325f7c17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 118 additions and 120 deletions

View File

@ -290,7 +290,7 @@ export default {
if (payer) { if (payer) {
payer = { payer = {
...payer, ...payer,
identifier: payer.identifier ? me.name : undefined identifier: payer.identifier ? `${me.name}@stacker.news` : undefined
} }
payer = Object.fromEntries( payer = Object.fromEntries(
Object.entries(payer).filter(([, value]) => !!value) Object.entries(payer).filter(([, value]) => !!value)
@ -305,10 +305,10 @@ export default {
callback.searchParams.append('comment', comment) callback.searchParams.append('comment', comment)
} }
let encodedPayerData = '' let stringifiedPayerData = ''
if (payer && Object.entries(payer).length) { if (payer && Object.entries(payer).length) {
encodedPayerData = encodeURIComponent(JSON.stringify(payer)) stringifiedPayerData = JSON.stringify(payer)
callback.searchParams.append('payerdata', encodedPayerData) callback.searchParams.append('payerdata', stringifiedPayerData)
} }
// call callback with amount and conditionally comment // call callback with amount and conditionally comment
@ -320,7 +320,7 @@ export default {
// decode invoice // decode invoice
try { try {
const decoded = await decodePaymentRequest({ lnd, request: res.pr }) const decoded = await decodePaymentRequest({ lnd, request: res.pr })
if (decoded.description_hash !== lnurlPayDescriptionHash(`${options.metadata}${encodedPayerData}`)) { if (decoded.description_hash !== lnurlPayDescriptionHash(`${options.metadata}${stringifiedPayerData}`)) {
throw new Error('description hash does not match') throw new Error('description hash does not match')
} }
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) { if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {

View File

@ -2,8 +2,6 @@ import { gql, useMutation } from '@apollo/client'
import Dropdown from 'react-bootstrap/Dropdown' import Dropdown from 'react-bootstrap/Dropdown'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useToast } from './toast' import { useToast } from './toast'
import { InvoiceModal, payOrLoginError } from './invoice'
import { DONT_LIKE_THIS_COST } from '../lib/constants'
import ItemAct from './item-act' import ItemAct from './item-act'
export default function DontLikeThisDropdownItem ({ id }) { export default function DontLikeThisDropdownItem ({ id }) {
@ -40,23 +38,8 @@ export default function DontLikeThisDropdownItem ({ id }) {
}} itemId={id} act={dontLikeThis} down }} itemId={id} act={dontLikeThis} down
/>) />)
} catch (error) { } catch (error) {
console.error(error)
if (payOrLoginError(error)) {
showModal(onClose => {
return (
<InvoiceModal
amount={DONT_LIKE_THIS_COST}
onPayment={async ({ hash, hmac }) => {
await dontLikeThis({ variables: { id, hash, hmac } })
toaster.success('item flagged')
}}
/>
)
})
} else {
toaster.danger('failed to flag item') toaster.danger('failed to flag item')
} }
}
}} }}
> >
flag flag

View File

@ -24,6 +24,7 @@ import { numWithUnits } from '../lib/format'
import textAreaCaret from 'textarea-caret' import textAreaCaret from 'textarea-caret'
import ReactDatePicker from 'react-datepicker' import ReactDatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css' import 'react-datepicker/dist/react-datepicker.css'
import { debounce } from './use-debounce-callback'
export function SubmitButton ({ export function SubmitButton ({
children, variant, value, onClick, disabled, cost, ...props children, variant, value, onClick, disabled, cost, ...props
@ -290,7 +291,7 @@ function FormGroup ({ className, label, children }) {
function InputInner ({ function InputInner ({
prepend, append, hint, showValid, onChange, onBlur, overrideValue, prepend, append, hint, showValid, onChange, onBlur, overrideValue,
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce, maxLength, innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength,
...props ...props
}) { }) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props) const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
@ -317,17 +318,11 @@ function InputInner ({
const invalid = (!formik || formik.submitCount > 0) && meta.touched && meta.error const invalid = (!formik || formik.submitCount > 0) && meta.touched && meta.error
const debounceRef = useRef(-1) useEffect(debounce(() => {
if (!noForm && !isNaN(debounceTime) && debounceTime > 0) {
useEffect(() => { formik.validateForm()
if (debounceRef.current !== -1) {
clearTimeout(debounceRef.current)
} }
if (!noForm && !isNaN(debounce) && debounce > 0) { }, debounceTime), [noForm, formik, field.value])
debounceRef.current = setTimeout(() => formik.validateForm(), debounce)
}
return () => clearTimeout(debounceRef.current)
}, [noForm, formik, field.value])
const remaining = maxLength && maxLength - (field.value || '').length const remaining = maxLength && maxLength - (field.value || '').length
@ -601,7 +596,7 @@ export function Form ({
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
useEffect(() => { useEffect(() => {
if (initialError && !initialErrorToasted) { if (initialError && !initialErrorToasted.current) {
toaster.danger(initialError.message || initialError.toString?.()) toaster.danger(initialError.message || initialError.toString?.())
initialErrorToasted.current = true initialErrorToasted.current = true
} }

View File

@ -149,6 +149,9 @@ const defaultOptions = {
callback: null, // (formValues) => void callback: null, // (formValues) => void
replaceModal: false replaceModal: false
} }
// TODO: refactor this so it can be easily understood
// there's lots of state cascading paired with logic
// independent of the state, and it's hard to follow
export const useInvoiceable = (onSubmit, options = defaultOptions) => { export const useInvoiceable = (onSubmit, options = defaultOptions) => {
const me = useMe() const me = useMe()
const [createInvoice, { data }] = useMutation(gql` const [createInvoice, { data }] = useMutation(gql`
@ -247,17 +250,14 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
// tell onSubmit handler that we want to keep local storage // tell onSubmit handler that we want to keep local storage
// even though the submit handler was "successful" // even though the submit handler was "successful"
return { keepLocalStorage: true } return { keepLocalStorage: true }
}, [onSubmit, setFormValues, setSubmitArgs, createInvoice]) }, [onSubmit, setFormValues, setSubmitArgs, createInvoice, !!me])
return onSubmitWrapper return onSubmitWrapper
} }
export const InvoiceModal = ({ onPayment, amount }) => { export const useInvoiceModal = (onPayment, deps) => {
const createInvoice = useInvoiceable(onPayment, { replaceModal: true }) const onPaymentMemo = useCallback(onPayment, deps)
return useInvoiceable(onPaymentMemo, { replaceModal: true })
useEffect(() => {
createInvoice({ amount })
}, [])
} }
export const payOrLoginError = (error) => { export const payOrLoginError = (error) => {

View File

@ -67,7 +67,7 @@ export default function ItemAct ({ onClose, itemId, act, down, strike }) {
return ( return (
<Form <Form
initial={{ initial={{
amount: me?.tipDefault, amount: me?.tipDefault || defaultTips[0],
default: false default: false
}} }}
schema={amountSchema} schema={amountSchema}

View File

@ -7,7 +7,7 @@ import { useMe } from './me'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useRoot } from './root' import { useRoot } from './root'
import { InvoiceModal, payOrLoginError } from './invoice' import { payOrLoginError, useInvoiceModal } from './invoice'
export default function PayBounty ({ children, item }) { export default function PayBounty ({ children, item }) {
const me = useMe() const me = useMe()
@ -59,6 +59,9 @@ export default function PayBounty ({ children, item }) {
} }
} }
) )
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
await act({ variables: { ...variables, hash, hmac } })
}, [act])
const handlePayBounty = async onComplete => { const handlePayBounty = async onComplete => {
try { try {
@ -74,16 +77,7 @@ export default function PayBounty ({ children, item }) {
onComplete() onComplete()
} catch (error) { } catch (error) {
if (payOrLoginError(error)) { if (payOrLoginError(error)) {
showModal(onClose => { showInvoiceModal({ amount: root.bounty }, { variables: { id: item.id, sats: root.bounty } })
return (
<InvoiceModal
amount={root.bounty}
onPayment={async ({ hash, hmac }) => {
await act({ variables: { id: item.id, sats: root.bounty, hash, hmac } })
}}
/>
)
})
return return
} }
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })

View File

@ -7,13 +7,11 @@ import styles from './poll.module.css'
import Check from '../svgs/checkbox-circle-fill.svg' import Check from '../svgs/checkbox-circle-fill.svg'
import { signIn } from 'next-auth/react' import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { useShowModal } from './modal'
import { POLL_COST } from '../lib/constants' import { POLL_COST } from '../lib/constants'
import { InvoiceModal } from './invoice' import { payOrLoginError, useInvoiceModal } from './invoice'
export default function Poll ({ item }) { export default function Poll ({ item }) {
const me = useMe() const me = useMe()
const showModal = useShowModal()
const [pollVote] = useMutation( const [pollVote] = useMutation(
gql` gql`
mutation pollVote($id: ID!, $hash: String, $hmac: String) { mutation pollVote($id: ID!, $hash: String, $hmac: String) {
@ -47,6 +45,12 @@ export default function Poll ({ item }) {
) )
const PollButton = ({ v }) => { const PollButton = ({ v }) => {
const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => {
await pollVote({ variables: { ...variables, hash, hmac } })
}, [pollVote])
const variables = { id: v.id }
return ( return (
<ActionTooltip placement='left' notForm> <ActionTooltip placement='left' notForm>
<Button <Button
@ -55,22 +59,17 @@ export default function Poll ({ item }) {
? async () => { ? async () => {
try { try {
await pollVote({ await pollVote({
variables: { id: v.id }, variables,
optimisticResponse: { optimisticResponse: {
pollVote: v.id pollVote: v.id
} }
}) })
} catch (error) { } catch (error) {
showModal(onClose => { if (payOrLoginError(error)) {
return ( showInvoiceModal({ amount: item.pollCost || POLL_COST }, { variables })
<InvoiceModal return
amount={item.pollCost || POLL_COST} }
onPayment={async ({ hash, hmac }) => { throw new Error({ message: error.toString() })
await pollVote({ variables: { id: v.id, hash, hmac } })
}}
/>
)
})
} }
} }
: signIn} : signIn}

View File

@ -5,14 +5,15 @@ import ActionTooltip from './action-tooltip'
import ItemAct from './item-act' import ItemAct from './item-act'
import { useMe } from './me' import { useMe } from './me'
import Rainbow from '../lib/rainbow' import Rainbow from '../lib/rainbow'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import LongPressable from 'react-longpressable' import LongPressable from 'react-longpressable'
import Overlay from 'react-bootstrap/Overlay' import Overlay from 'react-bootstrap/Overlay'
import Popover from 'react-bootstrap/Popover' import Popover from 'react-bootstrap/Popover'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { LightningConsumer, useLightning } from './lightning' import { LightningConsumer, useLightning } from './lightning'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
import { InvoiceModal, payOrLoginError } from './invoice' import { payOrLoginError, useInvoiceModal } from './invoice'
import useDebounceCallback from './use-debounce-callback'
const getColor = (meSats) => { const getColor = (meSats) => {
if (!meSats || meSats <= 10) { if (!meSats || meSats <= 10) {
@ -69,7 +70,6 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
const [voteShow, _setVoteShow] = useState(false) const [voteShow, _setVoteShow] = useState(false)
const [tipShow, _setTipShow] = useState(false) const [tipShow, _setTipShow] = useState(false)
const ref = useRef() const ref = useRef()
const timerRef = useRef(null)
const me = useMe() const me = useMe()
const strike = useLightning() const strike = useLightning()
const [setWalkthrough] = useMutation( const [setWalkthrough] = useMutation(
@ -153,21 +153,17 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
} }
} }
) )
const showInvoiceModal = useInvoiceModal(
async ({ hash, hmac }, { variables }) => {
await act({ variables: { ...variables, hash, hmac } })
strike()
}, [act, strike])
// if we want to use optimistic response, we need to buffer the votes const zap = useDebounceCallback(async (sats) => {
// because if someone votes in quick succession, responses come back out of order if (!sats) return
// so we wait a bit to see if there are more votes coming in const variables = { id: item.id, sats }
// this effectively performs our own debounced optimistic response
useEffect(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
if (pendingSats > 0) {
timerRef.current = setTimeout(async (sats) => {
const variables = { id: item.id, sats: pendingSats }
try { try {
timerRef.current && setPendingSats(0) setPendingSats(0)
await act({ await act({
variables, variables,
optimisticResponse: { optimisticResponse: {
@ -178,30 +174,12 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}) })
} catch (error) { } catch (error) {
if (payOrLoginError(error)) { if (payOrLoginError(error)) {
showModal(onClose => { showInvoiceModal({ amount: sats }, { variables })
return (
<InvoiceModal
amount={pendingSats}
onPayment={async ({ hash, hmac }) => {
await act({ variables: { ...variables, hash, hmac } })
strike()
}}
/>
)
})
return return
} }
if (!timerRef.current) return
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
}, 500, pendingSats) }, 500, [act, item?.id, showInvoiceModal, setPendingSats])
}
return async () => {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [pendingSats, act, item, showModal, setPendingSats])
const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt, const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt,
[item?.mine, item?.meForward, item?.deletedAt]) [item?.mine, item?.meForward, item?.deletedAt])
@ -258,7 +236,11 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
strike() strike()
setPendingSats(pendingSats + sats) setPendingSats(pendingSats => {
const zapAmount = pendingSats + sats
zap(zapAmount)
return zapAmount
})
} }
: () => showModal(onClose => <ItemAct onClose={onClose} itemId={item.id} act={act} strike={strike} />) : () => showModal(onClose => <ItemAct onClose={onClose} itemId={item.id} act={act} strike={strike} />)
} }

View File

@ -0,0 +1,25 @@
import { useCallback, useState } from 'react'
import useNoInitialEffect from './use-no-initial-effect'
export function debounce (fn, time) {
let timeoutId
return wrapper
function wrapper (...args) {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => {
timeoutId = null
fn(...args)
}, time)
// return a function that clears the timeout for use in useEffect cleanup
return () => clearTimeout(timeoutId)
}
}
export default function useDebounceCallback (fn, time, deps = []) {
const [args, setArgs] = useState([])
const memoFn = useCallback(fn, deps)
useNoInitialEffect(debounce(() => memoFn(...args), time), [memoFn, time, args, ...deps])
return useCallback((...args) => setArgs(args), [])
}

View File

@ -0,0 +1,13 @@
import { useEffect, useRef } from 'react'
export default function useNoInitialEffect (func, deps) {
const didMount = useRef(false)
useEffect(() => {
if (didMount.current) {
return func()
} else {
didMount.current = true
}
}, deps)
}

View File

@ -50,7 +50,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
let parsedPayerData let parsedPayerData
if (payerData) { if (payerData) {
try { try {
parsedPayerData = JSON.parse(decodeURIComponent(payerData)) parsedPayerData = JSON.parse(payerData)
} catch (err) { } catch (err) {
console.error('failed to parse payerdata', err) console.error('failed to parse payerdata', err)
return res.status(400).json({ status: 'ERROR', reason: 'Invalid JSON supplied for payerdata parameter' }) return res.status(400).json({ status: 'ERROR', reason: 'Invalid JSON supplied for payerdata parameter' })

View File

@ -21,6 +21,7 @@ import styles from '../components/user-header.module.css'
import HiddenWalletSummary from '../components/hidden-wallet-summary' import HiddenWalletSummary from '../components/hidden-wallet-summary'
import AccordianItem from '../components/accordian-item' import AccordianItem from '../components/accordian-item'
import { lnAddrOptions } from '../lib/lnurl' import { lnAddrOptions } from '../lib/lnurl'
import useDebounceCallback from '../components/use-debounce-callback'
export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -300,7 +301,13 @@ export function LnAddrWithdrawal () {
const [addrOptions, setAddrOptions] = useState(defaultOptions) const [addrOptions, setAddrOptions] = useState(defaultOptions)
const [formSchema, setFormSchema] = useState(lnAddrSchema()) const [formSchema, setFormSchema] = useState(lnAddrSchema())
const onAddrChange = async (formik, e) => { const onAddrChange = useDebounceCallback(async (formik, e) => {
if (!e?.target?.value) {
setAddrOptions(defaultOptions)
setFormSchema(lnAddrSchema())
return
}
let options let options
try { try {
options = await lnAddrOptions(e.target.value) options = await lnAddrOptions(e.target.value)
@ -312,7 +319,7 @@ export function LnAddrWithdrawal () {
setAddrOptions(options) setAddrOptions(options)
setFormSchema(lnAddrSchema(options)) setFormSchema(lnAddrSchema(options))
} }, 500, [setAddrOptions, setFormSchema])
return ( return (
<> <>