From bed68ab5edd89b0452c2543b70a96657cd91d90d Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 20 May 2026 13:35:46 -0700 Subject: [PATCH 1/3] fix: close scanner immediately and auto-continue on /send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveSendDestination becomes synchronous (dropped the redundant isValidLightningAddress pre-check; getInvoiceFromLud16 already surfaces invalid addresses via toast later in the send flow). The send-scanner no longer awaits quote/network work — it classifies, sets the destination on the store, marks a pendingContinue flag, and navigates to /send. SendInput consumes the flag on mount and auto-fires handleContinue, so the user lands on /send with the destination pinned and the Continue button already in its loading state. Errors surface as a toast on /send. Closes #1092 Co-Authored-By: Claude Opus 4.7 --- app/features/send/resolve-destination.ts | 9 +-- app/features/send/send-input.tsx | 13 +++- app/features/send/send-scanner.tsx | 79 +++--------------------- app/features/send/send-store.ts | 28 ++++++--- app/routes/_protected.send.tsx | 6 +- 5 files changed, 44 insertions(+), 91 deletions(-) diff --git a/app/features/send/resolve-destination.ts b/app/features/send/resolve-destination.ts index 2eef18fa6..5d6a2450c 100644 --- a/app/features/send/resolve-destination.ts +++ b/app/features/send/resolve-destination.ts @@ -1,6 +1,5 @@ import { type DecodedBolt11, parseBolt11Invoice } from '~/lib/bolt11'; import { parseCashuPaymentRequest } from '~/lib/cashu'; -import { isValidLightningAddress } from '~/lib/lnurl'; import type { Money } from '~/lib/money'; import { type Contact, isContact } from '../contacts/contact'; import { validateBolt11, validateLightningAddressFormat } from './validation'; @@ -33,10 +32,10 @@ type ResolveResult = | { success: true; data: SendDestination } | { success: false; error: string }; -export async function resolveSendDestination( +export function resolveSendDestination( input: string | Contact, { allowZeroAmountBolt11 = false }: { allowZeroAmountBolt11?: boolean } = {}, -): Promise { +): ResolveResult { if (isContact(input)) { return { success: true, @@ -50,10 +49,6 @@ export async function resolveSendDestination( } if (validateLightningAddressFormat(input) === true) { - const isValid = await isValidLightningAddress(input); - if (!isValid) { - return { success: false, error: 'Invalid lightning address' }; - } return { success: true, data: { diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index fbd6c280c..6f18370ab 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -6,7 +6,7 @@ import { X, ZapIcon, } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { MoneyInputDisplay } from '~/components/money-display'; import { Numpad } from '~/components/numpad'; import { @@ -70,6 +70,8 @@ export function SendInput() { const clearDestination = useSendStore((s) => s.clearDestination); const continueSend = useSendStore((s) => s.proceedWithSend); const status = useSendStore((s) => s.status); + const pendingContinue = useSendStore((s) => s.pendingContinue); + const setPendingContinue = useSendStore((s) => s.setPendingContinue); const sendAmountCurrencyUnit = sendAmount ? getDefaultUnit(sendAmount.currency) @@ -136,8 +138,15 @@ export function SendInput() { }); }; + // biome-ignore lint/correctness/useExhaustiveDependencies: only re-fire when the pending flag flips + useEffect(() => { + if (!pendingContinue || !destinationDisplay) return; + setPendingContinue(false); + handleContinue(inputValue, convertedValue); + }, [pendingContinue, destinationDisplay]); + const handleSelectDestination = async (destination: string | Contact) => { - const result = await selectDestination(destination); + const result = selectDestination(destination); if (!result.success) { toast({ title: 'Invalid destination', diff --git a/app/features/send/send-scanner.tsx b/app/features/send/send-scanner.tsx index cd3c1f77d..258392a55 100644 --- a/app/features/send/send-scanner.tsx +++ b/app/features/send/send-scanner.tsx @@ -5,95 +5,34 @@ import { PageHeaderTitle, } from '~/components/page'; import { QRScanner } from '~/components/qr-scanner'; -import { useExchangeRate } from '~/hooks/use-exchange-rate'; import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; -import type { Money } from '~/lib/money'; import { useNavigateWithViewTransition } from '~/lib/transitions/view-transition'; -import type { Account } from '../accounts/account'; -import { DomainError, getErrorMessage } from '../shared/error'; import { useSendStore } from './send-provider'; -/** - * Converts an amount to the send account currency. - * If the amount is already in the send account currency, returns the amount. - * - * @throws if the exchange rate fails to load - */ -const useConverter = (sendAccount: Account) => { - const otherCurrency = sendAccount.currency === 'BTC' ? 'USD' : 'BTC'; - - const { data: rate } = useExchangeRate( - `${otherCurrency}-${sendAccount.currency}`, - ); - - return (amount: Money) => { - if (!rate) throw new Error('Exchange rate not found'); - - return amount.convert(sendAccount.currency, rate); - }; -}; - export default function SendScanner() { const { toast } = useToast(); const navigate = useNavigateWithViewTransition(); const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); - const sendAccount = useSendStore((state) => state.getSourceAccount()); const selectDestination = useSendStore((state) => state.selectDestination); - const continueSend = useSendStore((state) => state.proceedWithSend); + const setPendingContinue = useSendStore((state) => state.setPendingContinue); - const convert = useConverter(sendAccount); - - const handleDecode = async (input: string) => { - const selectDestinationResult = await selectDestination(input); - if (!selectDestinationResult.success) { + const handleDecode = (input: string) => { + const result = selectDestination(input); + if (!result.success) { toast({ title: 'Invalid input', - description: selectDestinationResult.error, + description: result.error, variant: 'destructive', }); return; } - const { amount } = selectDestinationResult.data; - - if (!amount) { - // Navigate to send input to enter the amount - return navigate(buildLinkWithSearchParams('/send'), { - applyTo: 'oldView', - transition: 'slideDown', - }); - } - - const convertedAmount = - amount.currency !== sendAccount.currency ? convert(amount) : undefined; - const result = await continueSend(amount, convertedAmount); - - if (!result.success) { - const toastOptions = - result.error instanceof DomainError - ? { description: result.error.message } - : { - title: 'Error', - description: getErrorMessage( - result.error, - 'Failed to get a send quote. Please try again', - ), - variant: 'destructive' as const, - }; - - toast(toastOptions); - return; - } - - if (result.next !== 'confirmQuote') { - return; - } - - navigate(buildLinkWithSearchParams('/send/confirm'), { - applyTo: 'newView', - transition: 'slideUp', + setPendingContinue(true); + navigate(buildLinkWithSearchParams('/send'), { + applyTo: 'oldView', + transition: 'slideDown', }); }; diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index 5a63b0878..f70154ce6 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -73,6 +73,10 @@ type State = { * E.g. for agicash contact it's the username, for ln address it's the ln address, etc. */ destinationDisplay: string | null; + /** + * When true, SendInput auto-fires Continue on mount. + */ + pendingContinue: boolean; } & ( | { sendType: 'CASHU_TOKEN'; @@ -124,12 +128,12 @@ type Actions = { getSourceAccount: () => Account; selectDestination: ( destination: string | Contact, - ) => Promise< + ) => | { success: true; data: DecodedDestination } - | { success: false; error: string } - >; + | { success: false; error: string }; clearDestination: () => void; hasRequiredDestination: () => boolean; + setPendingContinue: (value: boolean) => void; proceedWithSend: ( amount: Money, convertedAmount: Money | undefined, @@ -214,6 +218,7 @@ export const createSendStore = ({ ? initialDestination.amount : null, quote: null, + pendingContinue: false, selectSourceAccount: (account) => { const { @@ -256,9 +261,9 @@ export const createSendStore = ({ } as Partial); }, - selectDestination: async (input) => { + selectDestination: (input) => { const account = get().getSourceAccount(); - const result = await resolveSendDestination(input, { + const result = resolveSendDestination(input, { allowZeroAmountBolt11: account.type === 'spark', }); if (!result.success) { @@ -282,26 +287,31 @@ export const createSendStore = ({ : null; const accountId = matched?.id ?? account.id; + const amount = + result.data.sendType === 'BOLT11_INVOICE' ? result.data.amount : null; + set({ accountId, sendType, destination, destinationDisplay, destinationDetails, + amount, } as Partial); return { success: true, data: { type: result.data.sendType, - amount: - result.data.sendType === 'BOLT11_INVOICE' - ? result.data.amount - : null, + amount, }, }; }, + setPendingContinue: (value) => { + set({ pendingContinue: value }); + }, + hasRequiredDestination: () => { const { sendType, destination, destinationDetails } = get(); switch (sendType) { diff --git a/app/routes/_protected.send.tsx b/app/routes/_protected.send.tsx index e633e655a..e00c22790 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -14,10 +14,10 @@ import { getQueryClient } from '~/features/shared/query-client'; import { toast } from '~/hooks/use-toast'; import type { Route } from './+types/_protected.send'; -export async function clientLoader(): Promise<{ +export function clientLoader(): { initialDestination: SendDestination | null; initialAccountId: string | null; -}> { +} { const hash = window.location.hash.slice(1); if (!hash) { return { initialDestination: null, initialAccountId: null }; @@ -31,7 +31,7 @@ export async function clientLoader(): Promise<{ window.location.pathname + window.location.search, ); - const result = await resolveSendDestination(hash, { + const result = resolveSendDestination(hash, { allowZeroAmountBolt11: true, }); From 66db8c2b0117594977d8b06624425066545ea530 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 20 May 2026 15:28:04 -0700 Subject: [PATCH 2/3] refactor: route through universal /scan, drop /send/scan and /receive/scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send and receive flows now use the universal /scan route instead of dedicated /send/scan and /receive/scan routes. /scan already does sync classification (classifyInput + validateBolt11) so the slowness from #1092 — scanner staying open during async network work — is gone: the scanner closes the moment a QR decodes. Scan icons on /send and /receive pass their currently selected account through the URL (accountId / selectedAccountId), which /scan preserves when forwarding to the destination route. Cross-flow scans (e.g. cashu token scanned while on /send) work because each destination route reads its own param name. No auto-continue: user clicks Continue manually on /send, same as when arriving via /scan from the home page today. resolveSendDestination stays synchronous (kept from previous commit) so the paste flow on /send also no longer pre-pings LNURL. Co-Authored-By: Claude Opus 4.7 --- app/features/receive/receive-input.tsx | 4 +- app/features/receive/receive-scanner.tsx | 62 ------------------------ app/features/send/send-input.tsx | 15 ++---- app/features/send/send-scanner.tsx | 54 --------------------- app/features/send/send-store.ts | 11 ----- app/routes/_protected.receive.scan.tsx | 10 ---- app/routes/_protected.scan.tsx | 6 ++- app/routes/_protected.send.scan.tsx | 10 ---- 8 files changed, 11 insertions(+), 161 deletions(-) delete mode 100644 app/features/receive/receive-scanner.tsx delete mode 100644 app/features/send/send-scanner.tsx delete mode 100644 app/routes/_protected.receive.scan.tsx delete mode 100644 app/routes/_protected.send.scan.tsx diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index 82145c390..def83c7d1 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -169,7 +169,9 @@ export default function ReceiveInput() { diff --git a/app/features/receive/receive-scanner.tsx b/app/features/receive/receive-scanner.tsx deleted file mode 100644 index ed067536d..000000000 --- a/app/features/receive/receive-scanner.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - ClosePageButton, - PageContent, - PageHeader, - PageHeaderTitle, -} from '~/components/page'; -import { QRScanner } from '~/components/qr-scanner'; -import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; -import { useToast } from '~/hooks/use-toast'; -import { extractCashuToken } from '~/lib/cashu'; -import { useNavigateWithViewTransition } from '~/lib/transitions'; -import { useReceiveStore } from './receive-provider'; - -export default function ReceiveScanner() { - const { toast } = useToast(); - const navigate = useNavigateWithViewTransition(); - const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); - const receiveAccountId = useReceiveStore((s) => s.accountId); - - return ( - <> - - - Scan - - - { - const encodedToken = extractCashuToken(scannedContent)?.encoded; - if (!encodedToken) { - toast({ - title: 'Invalid input', - description: 'Please scan a valid cashu token', - variant: 'destructive', - }); - return; - } - - const hash = `#${encodedToken}`; - - // The hash needs to be set manually before navigating or clientLoader of the destination route won't see it - // See https://github.com/remix-run/remix/discussions/10721 - window.history.replaceState(null, '', hash); - navigate( - { - ...buildLinkWithSearchParams('/receive/cashu/token', { - selectedAccountId: receiveAccountId, - }), - hash, - }, - { transition: 'slideLeft', applyTo: 'newView' }, - ); - }} - /> - - - ); -} diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index 6f18370ab..94574396c 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -6,7 +6,7 @@ import { X, ZapIcon, } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { MoneyInputDisplay } from '~/components/money-display'; import { Numpad } from '~/components/numpad'; import { @@ -70,8 +70,6 @@ export function SendInput() { const clearDestination = useSendStore((s) => s.clearDestination); const continueSend = useSendStore((s) => s.proceedWithSend); const status = useSendStore((s) => s.status); - const pendingContinue = useSendStore((s) => s.pendingContinue); - const setPendingContinue = useSendStore((s) => s.setPendingContinue); const sendAmountCurrencyUnit = sendAmount ? getDefaultUnit(sendAmount.currency) @@ -138,13 +136,6 @@ export function SendInput() { }); }; - // biome-ignore lint/correctness/useExhaustiveDependencies: only re-fire when the pending flag flips - useEffect(() => { - if (!pendingContinue || !destinationDisplay) return; - setPendingContinue(false); - handleContinue(inputValue, convertedValue); - }, [pendingContinue, destinationDisplay]); - const handleSelectDestination = async (destination: string | Contact) => { const result = selectDestination(destination); if (!result.success) { @@ -250,7 +241,9 @@ export function SendInput() { diff --git a/app/features/send/send-scanner.tsx b/app/features/send/send-scanner.tsx deleted file mode 100644 index 258392a55..000000000 --- a/app/features/send/send-scanner.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { - ClosePageButton, - PageContent, - PageHeader, - PageHeaderTitle, -} from '~/components/page'; -import { QRScanner } from '~/components/qr-scanner'; -import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; -import { useToast } from '~/hooks/use-toast'; -import { useNavigateWithViewTransition } from '~/lib/transitions/view-transition'; -import { useSendStore } from './send-provider'; - -export default function SendScanner() { - const { toast } = useToast(); - const navigate = useNavigateWithViewTransition(); - const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); - - const selectDestination = useSendStore((state) => state.selectDestination); - const setPendingContinue = useSendStore((state) => state.setPendingContinue); - - const handleDecode = (input: string) => { - const result = selectDestination(input); - if (!result.success) { - toast({ - title: 'Invalid input', - description: result.error, - variant: 'destructive', - }); - return; - } - - setPendingContinue(true); - navigate(buildLinkWithSearchParams('/send'), { - applyTo: 'oldView', - transition: 'slideDown', - }); - }; - - return ( - <> - - - Scan - - - - - - ); -} diff --git a/app/features/send/send-store.ts b/app/features/send/send-store.ts index f70154ce6..031f46480 100644 --- a/app/features/send/send-store.ts +++ b/app/features/send/send-store.ts @@ -73,10 +73,6 @@ type State = { * E.g. for agicash contact it's the username, for ln address it's the ln address, etc. */ destinationDisplay: string | null; - /** - * When true, SendInput auto-fires Continue on mount. - */ - pendingContinue: boolean; } & ( | { sendType: 'CASHU_TOKEN'; @@ -133,7 +129,6 @@ type Actions = { | { success: false; error: string }; clearDestination: () => void; hasRequiredDestination: () => boolean; - setPendingContinue: (value: boolean) => void; proceedWithSend: ( amount: Money, convertedAmount: Money | undefined, @@ -218,7 +213,6 @@ export const createSendStore = ({ ? initialDestination.amount : null, quote: null, - pendingContinue: false, selectSourceAccount: (account) => { const { @@ -296,7 +290,6 @@ export const createSendStore = ({ destination, destinationDisplay, destinationDetails, - amount, } as Partial); return { @@ -308,10 +301,6 @@ export const createSendStore = ({ }; }, - setPendingContinue: (value) => { - set({ pendingContinue: value }); - }, - hasRequiredDestination: () => { const { sendType, destination, destinationDetails } = get(); switch (sendType) { diff --git a/app/routes/_protected.receive.scan.tsx b/app/routes/_protected.receive.scan.tsx deleted file mode 100644 index 5f61a35d3..000000000 --- a/app/routes/_protected.receive.scan.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Page } from '~/components/page'; -import ReceiveScanner from '~/features/receive/receive-scanner'; - -export default function ReceiveScan() { - return ( - - - - ); -} diff --git a/app/routes/_protected.scan.tsx b/app/routes/_protected.scan.tsx index 5d55eda8f..1d6ae908e 100644 --- a/app/routes/_protected.scan.tsx +++ b/app/routes/_protected.scan.tsx @@ -11,6 +11,7 @@ import { Button } from '~/components/ui/button'; import { classifyInput } from '~/features/scan'; import { validateBolt11 } from '~/features/send/validation'; import useIsPwa from '~/hooks/use-is-pwa'; +import { useBuildLinkWithSearchParams } from '~/hooks/use-search-params-link'; import { useToast } from '~/hooks/use-toast'; import { readClipboard } from '~/lib/read-clipboard'; import { useNavigateWithViewTransition } from '~/lib/transitions'; @@ -20,6 +21,7 @@ export default function ScanPage() { const navigate = useNavigateWithViewTransition(); const { toast } = useToast(); const isPwa = useIsPwa(); + const buildLinkWithSearchParams = useBuildLinkWithSearchParams(); const handleInput = (raw: string) => { const result = classifyInput(raw); @@ -40,7 +42,7 @@ export default function ScanPage() { // See https://github.com/remix-run/remix/discussions/10721 window.history.replaceState(null, '', hash); navigate( - { pathname: '/receive/cashu/token', hash }, + { ...buildLinkWithSearchParams('/receive/cashu/token'), hash }, { transition: 'slideLeft', applyTo: 'newView' }, ); return; @@ -65,7 +67,7 @@ export default function ScanPage() { // See https://github.com/remix-run/remix/discussions/10721 window.history.replaceState(null, '', hash); navigate( - { pathname: '/send', hash }, + { ...buildLinkWithSearchParams('/send'), hash }, { transition: 'slideLeft', applyTo: 'newView' }, ); }; diff --git a/app/routes/_protected.send.scan.tsx b/app/routes/_protected.send.scan.tsx deleted file mode 100644 index 16c8f1399..000000000 --- a/app/routes/_protected.send.scan.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Page } from '~/components/page'; -import SendScanner from '~/features/send/send-scanner'; - -export default function SendScanPage() { - return ( - - - - ); -} From 81552a5a23082e7c7fdee1ac11fc79860b335c28 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 20 May 2026 15:34:33 -0700 Subject: [PATCH 3/3] fix(scan): slide down to close instead of left on classify navigate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /scan route is entered with a slideUp transition (Scan icons on /, /send, /receive); the close-X button slides down. Make the classify-handler navigations match — close the scanner downward on successful scan instead of sliding left to the destination route. Co-Authored-By: Claude Opus 4.7 --- app/routes/_protected.scan.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/_protected.scan.tsx b/app/routes/_protected.scan.tsx index 1d6ae908e..37f2ef304 100644 --- a/app/routes/_protected.scan.tsx +++ b/app/routes/_protected.scan.tsx @@ -43,7 +43,7 @@ export default function ScanPage() { window.history.replaceState(null, '', hash); navigate( { ...buildLinkWithSearchParams('/receive/cashu/token'), hash }, - { transition: 'slideLeft', applyTo: 'newView' }, + { transition: 'slideDown', applyTo: 'oldView' }, ); return; } @@ -68,7 +68,7 @@ export default function ScanPage() { window.history.replaceState(null, '', hash); navigate( { ...buildLinkWithSearchParams('/send'), hash }, - { transition: 'slideLeft', applyTo: 'newView' }, + { transition: 'slideDown', applyTo: 'oldView' }, ); };