Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/features/receive/ReceivePaymentDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,24 @@ describe('a created invoice', () => {
expect(screen.queryByText('Scan to pay this Lightning invoice')).toBeNull();
});
});

describe('receive tabs', () => {
it('takes the tabs away once an invoice exists, and gives a way back', async () => {
await openDialog();

expect(await screen.findByTestId('btc-tab')).toBeInTheDocument();
fireEvent.click(await screen.findByTestId('show-amount-panel-button'));
fireEvent.change(await screen.findByTestId('invoice-amount-input'), { target: { value: '5000' } });
fireEvent.click(screen.getByTestId('generate-invoice-button'));

await screen.findByTestId('lightning-invoice-text');
expect(screen.queryByTestId('btc-tab')).toBeNull();
expect(screen.queryByTestId('usd-tab')).toBeNull();

// `useSheetBack` registers through an effect, so the arrow lands a render late.
fireEvent.click(await screen.findByRole('button', { name: 'Back' }));

expect(await screen.findByTestId('show-amount-panel-button')).toBeInTheDocument();
expect(screen.getByTestId('btc-tab')).toBeInTheDocument();
});
});
30 changes: 25 additions & 5 deletions src/features/receive/ReceivePaymentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ const QRCodeDisplay: React.FC<QRCodeDisplayProps> = ({ paymentData, feeSats, amo
const ReceivePaymentDialog: React.FC<ReceivePaymentDialogProps> = ({ isOpen, onClose }) => {
const receive = useReceivePayment();
const [showChangeConfirm, setShowChangeConfirm] = useState<boolean>(false);
// The tabs belong to choosing, not to what you have made with the choice:
// past the USD amount form there is a quote to lose, and on a created
// invoice there is an invoice. The header arrow carries the way back from
// both instead.
const [usdResting, setUsdResting] = useState<boolean>(true);

// First-paint deferral. On a fresh post-install launch the main
// thread is still contending with WASM compile + SDK connect
Expand Down Expand Up @@ -284,6 +289,9 @@ const ReceivePaymentDialog: React.FC<ReceivePaymentDialogProps> = ({ isOpen, onC
// Cleared, so a QR replacing its placeholder does not turn in again.
later(320, () => setTurning(null));
};
// Tabs while the user is choosing what to receive, gone once there is
// something made: a USD request past its amount form, or a created invoice.
const tabsVisible = receive.activeTab === 'usd' ? usdResting : receive.currentStep === 'input';
// Under 400px the code shrinks so the switch and label have room beside its corners.
const qrSize = window.innerWidth < 400 ? 184 : 200;
const qrCardClassName = `${turning === 'out' ? 'animate-qr-turn-out' : turning === 'close' ? 'qr-turn-close' : turning === 'in' ? 'animate-qr-turn-in' : pendingMode ? 'qr-turn-wait' : ''} motion-reduce:animate-none`;
Expand Down Expand Up @@ -325,12 +333,16 @@ const ReceivePaymentDialog: React.FC<ReceivePaymentDialogProps> = ({ isOpen, onC
<DialogHeader
title="Receive"
onClose={onClose}
// The USD steps lend theirs through `useSheetBack`; the invoice is
// rendered here, so its way back is passed in directly.
onBack={receive.currentStep === 'qr' ? receive.dismissInvoice : undefined}
icon={<ArrowDownIcon />}
/>

{isContentReady ? (
<TabContainer>
<TabList>
{tabsVisible && (
<TabList>
<Tab isActive={isBtcTab} onClick={() => { if (!isBtcTab) { handleTabChange('lightning'); setShownMode('lightning'); } }} data-testid="btc-tab">
<span className="font-bold text-sm">₿</span>
BTC
Expand All @@ -339,14 +351,22 @@ const ReceivePaymentDialog: React.FC<ReceivePaymentDialogProps> = ({ isOpen, onC
<span className="font-bold text-sm">$</span>
USD
</Tab>
</TabList>
</TabList>
)}

{/* The USD tab sits outside StepContainer: its steps size to their
own content (matching the cross-chain send flow), so the 280px
floor would pad the short ones out with dead space. */}
{receive.activeTab === 'usd' ? (
<CrossChainReceiveWorkflow key={`usd-${receive.resetCount}`} />
) : (
{/* Stays mounted across tab switches and renders nothing while it
is off screen: a quote, its network and the typed amount are
too much to lose to a stray tap on BTC. Unkeyed for the same
reason, since `resetCount` is bumped by closing the amount
panel, which has nothing to do with this flow. */}
<CrossChainReceiveWorkflow
active={receive.activeTab === 'usd'}
onRestingChange={setUsdResting}
/>
{receive.activeTab !== 'usd' && (
<StepContainer>
<>
{receive.currentStep === 'input' && (
Expand Down
17 changes: 17 additions & 0 deletions src/features/receive/hooks/useReceivePayment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface UseReceivePaymentReturn {
// available as an escape hatch for the SDK-error-recovery path
// where the amount must survive the panel closing and reopening.
closeAmountPanel: () => void;
/** Leaves a created invoice for the address view; the invoice stays payable. */
dismissInvoice: () => void;
handleTabChange: (tab: PaymentMethod, loadLightningAddress: () => void) => void;
generateBitcoinAddress: () => Promise<void>;
generateBolt11Invoice: () => Promise<void>;
Expand Down Expand Up @@ -232,6 +234,20 @@ export function useReceivePayment(): UseReceivePaymentReturn {
}
}, [wallet, amountSats, amountDisplay, description, showAmountPanel]);

// Back from a created invoice to the address view. The invoice itself stays
// valid and payable: nothing here revokes it, it just leaves the screen.
// Clears the amount so the next trip through the panel starts empty.
const dismissInvoice = useCallback(() => {
setCurrentStep('input');
setPaymentData('');
setFeeSats(0);
setError(null);
setAmountSats(null);
setDescription('');
setInvoiceAmount(null);
setResetCount((c) => c + 1);
}, []);

const handleTabChange = useCallback((tab: PaymentMethod, loadLightningAddress: () => void) => {
setActiveTab(tab);
setCurrentStep('input');
Expand Down Expand Up @@ -271,6 +287,7 @@ export function useReceivePayment(): UseReceivePaymentReturn {
setAmountDisplay,
setShowAmountPanel,
closeAmountPanel,
dismissInvoice,
handleTabChange,
generateBitcoinAddress,
generateBolt11Invoice,
Expand Down
19 changes: 16 additions & 3 deletions src/features/receive/workflows/CrossChainReceiveWorkflow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import type {
CrossChainReceiveInfo,
CrossChainRoutePair,
Expand Down Expand Up @@ -40,7 +40,16 @@ type WorkflowStep = 'amount' | 'loading' | 'asset' | 'chain' | 'provider' | 'gen

const QUICK_USD_AMOUNTS = [10, 50, 200];

const CrossChainReceiveWorkflow: React.FC = () => {
interface CrossChainReceiveWorkflowProps {
/** Whether the USD tab is the one on screen. Kept mounted while it is not,
* so a trip to BTC and back does not discard a typed amount or a quote. */
active: boolean;
/** True while the flow is still on its amount form, which is where the tabs
* still belong. */
onRestingChange?: (resting: boolean) => void;
}

const CrossChainReceiveWorkflow: React.FC<CrossChainReceiveWorkflowProps> = ({ active, onRestingChange }) => {
const wallet = useWallet();
const stableBalance = useStableBalance();
const { showToast } = useToast();
Expand All @@ -62,7 +71,9 @@ const CrossChainReceiveWorkflow: React.FC = () => {

// The provider and result steps cap and scroll themselves, same as the
// asset / chain steps do from inside their own components.
useSheetOwnsScroll(step === 'provider' || step === 'result');
useSheetOwnsScroll(active && (step === 'provider' || step === 'result'));

useEffect(() => { onRestingChange?.(step === 'amount'); }, [step, onRestingChange]);

const { uniqueAssets, chainGroupKey, getChainsForAsset } = useCrossChainRouteGroups(routes);
const chainsForAsset = selectedAsset ? getChainsForAsset(selectedAsset) : [];
Expand Down Expand Up @@ -259,6 +270,8 @@ const CrossChainReceiveWorkflow: React.FC = () => {
// short step is not padded out to the tallest one. `pt-6` is the step padding
// the other receive tabs use. The selection lists cap themselves against the
// viewport (see CrossChainAssetStep) rather than against this container.
if (!active) return null;

return (
<div className="pt-6">
{/* Step 1: Amount */}
Expand Down
Loading