diff --git a/src/components/OnchainZapContent.tsx b/src/components/OnchainZapContent.tsx index 287ed9ab..6596e392 100644 --- a/src/components/OnchainZapContent.tsx +++ b/src/components/OnchainZapContent.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; -import { AlertTriangle, Zap, Gauge, Loader2, Bitcoin } from 'lucide-react'; +import { AlertTriangle, Zap, Gauge, Loader2, Bitcoin, Copy, Check } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; @@ -11,10 +11,13 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { Textarea } from '@/components/ui/textarea'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap'; +import { useToast } from '@/hooks/useToast'; import { useNostrLogin } from '@nostrify/react/login'; import { nostrPubkeyToBitcoinAddress, @@ -77,13 +80,14 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) const { data: utxos } = useQuery({ queryKey: ['bitcoin-utxos', senderAddress], queryFn: () => fetchUTXOs(senderAddress), - enabled: !!senderAddress, + enabled: !!senderAddress && capability !== 'unsupported', staleTime: 30_000, }); const { data: feeRates } = useQuery({ queryKey: ['bitcoin-fee-rates'], queryFn: getFeeRates, + enabled: capability !== 'unsupported', staleTime: 30_000, }); @@ -164,29 +168,24 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) }, [user, target.pubkey, btcPrice, amountSats, utxos, insufficient, zapAsync, comment, feeSpeed, isLarge, confirmArmed]); // ── Signer not supported ────────────────────────────────────── + // The user's signer can't sign PSBTs locally (extension without signPsbt, + // or a bunker that rejected sign_psbt). Instead of a dead-end, show a QR + // they can scan with any external Bitcoin wallet. We can't observe the + // resulting txid, so we don't publish a kind 8333 — the user is warned + // that the zap won't be attributed to them on Nostr. if (user && capability === 'unsupported') { - // Tailor the hint to the login type so the user knows what to change - // to regain Bitcoin-zap capability. - const message = - loginType === 'extension' - ? "Your browser extension doesn't support sending Bitcoin. Try a different extension, or log in with your secret key." - : loginType === 'bunker' - ? "Your remote signer doesn't support sending Bitcoin. Update your signer, or log in with your secret key." - : "Log in with your secret key to send Bitcoin zaps."; - return ( -
-
- -
-
-

Bitcoin zaps aren't available

-

- {message} -

-
-
+ ); } @@ -348,3 +347,195 @@ function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting default: return 'Processing…'; } } + +// ────────────────────────────────────────────────────────────── +// Unsupported-signer QR fallback +// ────────────────────────────────────────────────────────────── + +interface UnsupportedSignerQRProps { + recipientAddress: string; + truncatedRecipient: string; + amountSats: number; + btcPrice: number | undefined; + usdAmount: number | string; + setUsdAmount: (v: number | string) => void; + loginType: string | undefined; + onClose?: () => void; +} + +/** + * Fallback shown when the user's signer can't sign PSBTs locally. Renders a + * BIP-21 QR the user can scan with any external Bitcoin wallet. Because we + * never see the resulting tx, we skip publishing the kind 8333 zap event and + * explicitly warn the user about that. + */ +function UnsupportedSignerQR({ + recipientAddress, + truncatedRecipient, + amountSats, + btcPrice, + usdAmount, + setUsdAmount, + loginType, + onClose, +}: UnsupportedSignerQRProps) { + const { toast } = useToast(); + const [copied, setCopied] = useState<'address' | 'uri' | null>(null); + + // BIP-21 URI. Include `amount` (in BTC, 8 decimals) only when > 0 so an + // empty-amount placeholder QR doesn't include `?amount=0`. + const bip21 = useMemo(() => { + if (!recipientAddress) return ''; + if (amountSats <= 0) return `bitcoin:${recipientAddress}`; + const btc = (amountSats / 100_000_000).toFixed(8); + return `bitcoin:${recipientAddress}?amount=${btc}`; + }, [recipientAddress, amountSats]); + + const explanation = + loginType === 'extension' + ? "Your browser extension can't sign Bitcoin transactions." + : loginType === 'bunker' + ? "Your remote signer can't sign Bitcoin transactions." + : "Your signer can't sign Bitcoin transactions."; + + const copy = useCallback( + async (value: string, which: 'address' | 'uri', label: string) => { + try { + await navigator.clipboard.writeText(value); + setCopied(which); + toast({ title: 'Copied', description: `${label} copied to clipboard` }); + setTimeout(() => setCopied(null), 2000); + } catch { + toast({ title: 'Copy failed', description: 'Please copy manually.', variant: 'destructive' }); + } + }, + [toast], + ); + + const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount; + const hasAmount = amountSats > 0; + + return ( +
+

+ {explanation} You can still zap by scanning this QR from any Bitcoin wallet. +

+ + {/* Amount presets (USD) */} + { if (v) setUsdAmount(Number(v)); }} + className="grid grid-cols-5 gap-1 w-full" + > + {USD_PRESETS.map((v) => ( + + ${v} + + ))} + + +
+
+ OR +
+
+ +
+ $ + setUsdAmount(e.target.value)} + className="pl-6" + /> +
+ + {/* QR / placeholder */} +
+ {hasAmount && bip21 ? ( +
+ +
+ ) : ( +
+ {btcPrice + ? 'Choose an amount above to generate a payment QR.' + : 'Loading BTC price…'} +
+ )} +
+ + {/* Amount summary */} + {hasAmount && btcPrice && ( +
+ + {currentUsd > 0 ? `$${currentUsd}` : ''} + + + {' · '}{formatSats(amountSats)} sats + +
+ )} + + {/* Recipient */} + {recipientAddress && ( +
+
+ + To: + {truncatedRecipient} +
+
+ )} + + {/* Copy buttons */} +
+ + +
+ + {/* Warning: no kind 8333 will be published */} + + + + Because we can't see your transaction, this zap won't show up as yours on Nostr. The recipient will still get the Bitcoin. + + + + {onClose && ( + + )} +
+ ); +}