From e9c0c067797b6698aceade84ad7fa57073333cc0 Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Thu, 5 Oct 2023 10:27:01 -0400 Subject: [PATCH 1/5] Various LUD-18 fixes * Debounce the `onAddrChange` event handler when sending to a LN Address, so we more accurately display the input form for LUD-12 and LUD-18 options * Remove explicit URI encoding of Payer Data when sending to a LN Addr, since we're getting encoding for free via URLSearchParams * Append `@stacker.news` to identifier values sent in payer data * Don't do extra decoding when receiving LUD-18 data --- api/resolvers/wallet.js | 10 +++++----- package-lock.json | 1 + package.json | 1 + pages/api/lnurlp/[username]/pay.js | 2 +- pages/wallet.js | 7 ++++--- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 7259ceeb..07915631 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -290,7 +290,7 @@ export default { if (payer) { payer = { ...payer, - identifier: payer.identifier ? me.name : undefined + identifier: payer.identifier ? `${me.name}@stacker.news` : undefined } payer = Object.fromEntries( Object.entries(payer).filter(([, value]) => !!value) @@ -305,10 +305,10 @@ export default { callback.searchParams.append('comment', comment) } - let encodedPayerData = '' + let stringifiedPayerData = '' if (payer && Object.entries(payer).length) { - encodedPayerData = encodeURIComponent(JSON.stringify(payer)) - callback.searchParams.append('payerdata', encodedPayerData) + stringifiedPayerData = JSON.stringify(payer) + callback.searchParams.append('payerdata', stringifiedPayerData) } // call callback with amount and conditionally comment @@ -320,7 +320,7 @@ export default { // decode invoice try { 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') } if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) { diff --git a/package-lock.json b/package-lock.json index 16f12a70..c4562a35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "graphql-type-json": "^0.3.2", "jose1": "npm:jose@^1.27.2", "ln-service": "^56.11.0", + "lodash.debounce": "^4.0.8", "mathjs": "^11.9.1", "mdast-util-find-and-replace": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", diff --git a/package.json b/package.json index af34d1df..3fcb7907 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "graphql-type-json": "^0.3.2", "jose1": "npm:jose@^1.27.2", "ln-service": "^56.11.0", + "lodash.debounce": "^4.0.8", "mathjs": "^11.9.1", "mdast-util-find-and-replace": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 7efe6af3..ed2513d8 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -50,7 +50,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa let parsedPayerData if (payerData) { try { - parsedPayerData = JSON.parse(decodeURIComponent(payerData)) + parsedPayerData = JSON.parse(payerData) } catch (err) { console.error('failed to parse payerdata', err) return res.status(400).json({ status: 'ERROR', reason: 'Invalid JSON supplied for payerdata parameter' }) diff --git a/pages/wallet.js b/pages/wallet.js index 7ea3111b..d011c212 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -8,7 +8,7 @@ import { CenterLayout } from '../components/layout' import InputGroup from 'react-bootstrap/InputGroup' import { WithdrawlSkeleton } from './withdrawals/[id]' import { useMe } from '../components/me' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { requestProvider } from 'webln' import Alert from 'react-bootstrap/Alert' import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '../fragments/wallet' @@ -21,6 +21,7 @@ import styles from '../components/user-header.module.css' import HiddenWalletSummary from '../components/hidden-wallet-summary' import AccordianItem from '../components/accordian-item' import { lnAddrOptions } from '../lib/lnurl' +import debounce from 'lodash.debounce' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -300,7 +301,7 @@ export function LnAddrWithdrawal () { const [addrOptions, setAddrOptions] = useState(defaultOptions) const [formSchema, setFormSchema] = useState(lnAddrSchema()) - const onAddrChange = async (formik, e) => { + const onAddrChange = useCallback(debounce(async (formik, e) => { let options try { options = await lnAddrOptions(e.target.value) @@ -312,7 +313,7 @@ export function LnAddrWithdrawal () { setAddrOptions(options) setFormSchema(lnAddrSchema(options)) - } + }, 500), [lnAddrOptions, lnAddrSchema]) return ( <> From d1a7dca46b145ef6c6aeaafd9761affde97ca3ec Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 5 Oct 2023 20:33:14 -0500 Subject: [PATCH 2/5] use and make a debounce callback hook --- components/form.js | 17 ++++++----------- components/use-debounce-callback.js | 23 +++++++++++++++++++++++ package-lock.json | 1 - package.json | 1 - pages/wallet.js | 8 ++++---- 5 files changed, 33 insertions(+), 17 deletions(-) create mode 100644 components/use-debounce-callback.js diff --git a/components/form.js b/components/form.js index bdbbcf19..3f523d11 100644 --- a/components/form.js +++ b/components/form.js @@ -24,6 +24,7 @@ import { numWithUnits } from '../lib/format' import textAreaCaret from 'textarea-caret' import ReactDatePicker from 'react-datepicker' import 'react-datepicker/dist/react-datepicker.css' +import { debounce } from './use-debounce-callback' export function SubmitButton ({ children, variant, value, onClick, disabled, cost, ...props @@ -290,7 +291,7 @@ function FormGroup ({ className, label, children }) { function InputInner ({ prepend, append, hint, showValid, onChange, onBlur, overrideValue, - innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce, maxLength, + innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, ...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 debounceRef = useRef(-1) - - useEffect(() => { - if (debounceRef.current !== -1) { - clearTimeout(debounceRef.current) + useEffect(debounce(() => { + if (!noForm && !isNaN(debounceTime) && debounceTime > 0) { + formik.validateForm() } - if (!noForm && !isNaN(debounce) && debounce > 0) { - debounceRef.current = setTimeout(() => formik.validateForm(), debounce) - } - return () => clearTimeout(debounceRef.current) - }, [noForm, formik, field.value]) + }, debounceTime), [noForm, formik, field.value]) const remaining = maxLength && maxLength - (field.value || '').length diff --git a/components/use-debounce-callback.js b/components/use-debounce-callback.js new file mode 100644 index 00000000..3d945f63 --- /dev/null +++ b/components/use-debounce-callback.js @@ -0,0 +1,23 @@ +import { useCallback, useEffect, useState } from 'react' + +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([]) + useEffect(debounce(() => fn(...args), time), [fn, time, args, ...deps]) + return useCallback((...args) => setArgs(args), []) +} diff --git a/package-lock.json b/package-lock.json index c4562a35..16f12a70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "graphql-type-json": "^0.3.2", "jose1": "npm:jose@^1.27.2", "ln-service": "^56.11.0", - "lodash.debounce": "^4.0.8", "mathjs": "^11.9.1", "mdast-util-find-and-replace": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", diff --git a/package.json b/package.json index 3fcb7907..af34d1df 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "graphql-type-json": "^0.3.2", "jose1": "npm:jose@^1.27.2", "ln-service": "^56.11.0", - "lodash.debounce": "^4.0.8", "mathjs": "^11.9.1", "mdast-util-find-and-replace": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", diff --git a/pages/wallet.js b/pages/wallet.js index d011c212..788ed1bf 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -8,7 +8,7 @@ import { CenterLayout } from '../components/layout' import InputGroup from 'react-bootstrap/InputGroup' import { WithdrawlSkeleton } from './withdrawals/[id]' import { useMe } from '../components/me' -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { requestProvider } from 'webln' import Alert from 'react-bootstrap/Alert' import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '../fragments/wallet' @@ -21,7 +21,7 @@ import styles from '../components/user-header.module.css' import HiddenWalletSummary from '../components/hidden-wallet-summary' import AccordianItem from '../components/accordian-item' import { lnAddrOptions } from '../lib/lnurl' -import debounce from 'lodash.debounce' +import useDebounceCallback from '../components/use-debounce-callback' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -301,7 +301,7 @@ export function LnAddrWithdrawal () { const [addrOptions, setAddrOptions] = useState(defaultOptions) const [formSchema, setFormSchema] = useState(lnAddrSchema()) - const onAddrChange = useCallback(debounce(async (formik, e) => { + const onAddrChange = useDebounceCallback(async (formik, e) => { let options try { options = await lnAddrOptions(e.target.value) @@ -313,7 +313,7 @@ export function LnAddrWithdrawal () { setAddrOptions(options) setFormSchema(lnAddrSchema(options)) - }, 500), [lnAddrOptions, lnAddrSchema]) + }, 500, [lnAddrOptions, lnAddrSchema]) return ( <> From 0a35bca650f90a40a0fd385fc22904b7ee566c5b Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 5 Oct 2023 21:14:57 -0500 Subject: [PATCH 3/5] memoize debounced function --- components/use-debounce-callback.js | 3 ++- pages/wallet.js | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/components/use-debounce-callback.js b/components/use-debounce-callback.js index 3d945f63..9eab0537 100644 --- a/components/use-debounce-callback.js +++ b/components/use-debounce-callback.js @@ -18,6 +18,7 @@ export function debounce (fn, time) { export default function useDebounceCallback (fn, time, deps = []) { const [args, setArgs] = useState([]) - useEffect(debounce(() => fn(...args), time), [fn, time, args, ...deps]) + const memoFn = useCallback(fn, deps) + useEffect(debounce(() => memoFn(...args), time), [memoFn, time, args, ...deps]) return useCallback((...args) => setArgs(args), []) } diff --git a/pages/wallet.js b/pages/wallet.js index 788ed1bf..fab71f40 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -302,6 +302,12 @@ export function LnAddrWithdrawal () { const [formSchema, setFormSchema] = useState(lnAddrSchema()) const onAddrChange = useDebounceCallback(async (formik, e) => { + if (!e?.target?.value) { + setAddrOptions(defaultOptions) + setFormSchema(lnAddrSchema()) + return + } + let options try { options = await lnAddrOptions(e.target.value) @@ -313,7 +319,7 @@ export function LnAddrWithdrawal () { setAddrOptions(options) setFormSchema(lnAddrSchema(options)) - }, 500, [lnAddrOptions, lnAddrSchema]) + }, 500, []) return ( <> From 8ca8bb985e3f744d1d6b793753dab7a09e6da9dd Mon Sep 17 00:00:00 2001 From: keyan Date: Fri, 6 Oct 2023 15:01:51 -0500 Subject: [PATCH 4/5] reuse debounce hook more places --- components/dont-link-this.js | 19 +------ components/invoice.js | 14 ++--- components/item-act.js | 2 +- components/pay-bounty.js | 16 ++---- components/poll.js | 27 +++++----- components/upvote.js | 80 +++++++++++------------------ components/use-debounce-callback.js | 5 +- components/use-no-initial-effect.js | 13 +++++ pages/wallet.js | 2 +- 9 files changed, 75 insertions(+), 103 deletions(-) create mode 100644 components/use-no-initial-effect.js diff --git a/components/dont-link-this.js b/components/dont-link-this.js index 90464e32..ae3c11d8 100644 --- a/components/dont-link-this.js +++ b/components/dont-link-this.js @@ -2,8 +2,6 @@ import { gql, useMutation } from '@apollo/client' import Dropdown from 'react-bootstrap/Dropdown' import { useShowModal } from './modal' import { useToast } from './toast' -import { InvoiceModal, payOrLoginError } from './invoice' -import { DONT_LIKE_THIS_COST } from '../lib/constants' import ItemAct from './item-act' export default function DontLikeThisDropdownItem ({ id }) { @@ -40,22 +38,7 @@ export default function DontLikeThisDropdownItem ({ id }) { }} itemId={id} act={dontLikeThis} down />) } catch (error) { - console.error(error) - if (payOrLoginError(error)) { - showModal(onClose => { - return ( - { - await dontLikeThis({ variables: { id, hash, hmac } }) - toaster.success('item flagged') - }} - /> - ) - }) - } else { - toaster.danger('failed to flag item') - } + toaster.danger('failed to flag item') } }} > diff --git a/components/invoice.js b/components/invoice.js index cb9e7218..04c2283e 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -149,6 +149,9 @@ const defaultOptions = { callback: null, // (formValues) => void 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) => { const me = useMe() 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 // even though the submit handler was "successful" return { keepLocalStorage: true } - }, [onSubmit, setFormValues, setSubmitArgs, createInvoice]) + }, [onSubmit, setFormValues, setSubmitArgs, createInvoice, !!me]) return onSubmitWrapper } -export const InvoiceModal = ({ onPayment, amount }) => { - const createInvoice = useInvoiceable(onPayment, { replaceModal: true }) - - useEffect(() => { - createInvoice({ amount }) - }, []) +export const useInvoiceModal = (onPayment, deps) => { + const onPaymentMemo = useCallback(onPayment, deps) + return useInvoiceable(onPaymentMemo, { replaceModal: true }) } export const payOrLoginError = (error) => { diff --git a/components/item-act.js b/components/item-act.js index ef50241b..d249074d 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -67,7 +67,7 @@ export default function ItemAct ({ onClose, itemId, act, down, strike }) { return (
{ + await act({ variables: { ...variables, hash, hmac } }) + }, [act]) const handlePayBounty = async onComplete => { try { @@ -74,16 +77,7 @@ export default function PayBounty ({ children, item }) { onComplete() } catch (error) { if (payOrLoginError(error)) { - showModal(onClose => { - return ( - { - await act({ variables: { id: item.id, sats: root.bounty, hash, hmac } }) - }} - /> - ) - }) + showInvoiceModal({ amount: root.bounty }, { variables: { id: item.id, sats: root.bounty } }) return } throw new Error({ message: error.toString() }) diff --git a/components/poll.js b/components/poll.js index 1a0b8fc6..987f96ee 100644 --- a/components/poll.js +++ b/components/poll.js @@ -7,13 +7,11 @@ import styles from './poll.module.css' import Check from '../svgs/checkbox-circle-fill.svg' import { signIn } from 'next-auth/react' import ActionTooltip from './action-tooltip' -import { useShowModal } from './modal' import { POLL_COST } from '../lib/constants' -import { InvoiceModal } from './invoice' +import { payOrLoginError, useInvoiceModal } from './invoice' export default function Poll ({ item }) { const me = useMe() - const showModal = useShowModal() const [pollVote] = useMutation( gql` mutation pollVote($id: ID!, $hash: String, $hmac: String) { @@ -47,6 +45,12 @@ export default function Poll ({ item }) { ) const PollButton = ({ v }) => { + const showInvoiceModal = useInvoiceModal(async ({ hash, hmac }, { variables }) => { + await pollVote({ variables: { ...variables, hash, hmac } }) + }, [pollVote]) + + const variables = { id: v.id } + return (