diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8e8b5fb0..bf97aae4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -60,4 +60,6 @@ + + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index c6f07b3b..74c1120c 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -50,7 +50,7 @@ NSPhotoLibraryUsageDescription Agora needs access to your photo library to upload images to your posts and profile. NSCameraUsageDescription - Agora needs camera access to take photos and videos for your posts. + Agora needs camera access to take photos and videos for your posts, and to scan QR codes when sending Bitcoin. NSMicrophoneUsageDescription Agora needs access to your microphone to record voice messages. ITSAppUsesNonExemptEncryption diff --git a/package-lock.json b/package-lock.json index e0bd2fc8..5a1e2b10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -105,6 +105,7 @@ "iso-3166": "^4.4.0", "lucide-react": "^1.8.0", "nostr-tools": "^2.13.0", + "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", "react": "^19.2.4", "react-blurhash": "^0.3.0", @@ -6226,6 +6227,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/qrcode": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", @@ -11997,6 +12004,15 @@ "node": ">=6" } }, + "node_modules/qr-scanner": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz", + "integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==", + "license": "MIT", + "dependencies": { + "@types/offscreencanvas": "^2019.6.4" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", diff --git a/package.json b/package.json index b5035924..8860b87b 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "iso-3166": "^4.4.0", "lucide-react": "^1.8.0", "nostr-tools": "^2.13.0", + "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", "react": "^19.2.4", "react-blurhash": "^0.3.0", diff --git a/src/components/BitcoinRecipientInput.tsx b/src/components/BitcoinRecipientInput.tsx index 80566b52..4a46e5b1 100644 --- a/src/components/BitcoinRecipientInput.tsx +++ b/src/components/BitcoinRecipientInput.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Loader2, Search } from 'lucide-react'; +import { Loader2, QrCode, Search } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; @@ -10,12 +10,21 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; interface BitcoinRecipientInputProps { value: string; onChange: (value: string) => void; placeholder: string; resolvedPubkey?: string; + /** + * When provided, a camera button is rendered inside the input that opens + * a QR scanner. The parent owns the scanner lifecycle and interprets the + * scan result (BIP-21 parsing, silent-payment priority, etc.). + */ + onScanClick?: () => void; + /** Localized aria-label for the scan button (only used when `onScanClick` is set). */ + scanLabel?: string; } function shouldSkipProfileSearch(value: string): boolean { @@ -33,7 +42,7 @@ function shouldSkipProfileSearch(value: string): boolean { ); } -export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPubkey }: BitcoinRecipientInputProps) { +export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPubkey, onScanClick, scanLabel }: BitcoinRecipientInputProps) { const [dropdownOpen, setDropdownOpen] = useState(false); const inputRef = useRef(null); const searchQuery = shouldSkipProfileSearch(value) ? '' : value; @@ -67,7 +76,12 @@ export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPu
{isFetching && shouldShowSearch && ( - + )} + {onScanClick && ( + + )}
diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx index 5ba27637..0aa0784b 100644 --- a/src/components/HDSendBitcoinDialog.tsx +++ b/src/components/HDSendBitcoinDialog.tsx @@ -26,6 +26,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker'; import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer'; import { BitcoinRecipientInput } from '@/components/BitcoinRecipientInput'; +import { QrScannerDialog } from '@/components/QrScannerDialog'; import { HelpTip } from '@/components/HelpTip'; import { cn } from '@/lib/utils'; @@ -42,6 +43,7 @@ import { import { isLargeAmount, nostrPubkeyToBitcoinAddress, + parseBitcoinUri, satsToUSD, } from '@/lib/bitcoin'; import { @@ -228,11 +230,39 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien const [error, setError] = useState(''); const [feePopoverOpen, setFeePopoverOpen] = useState(false); const [success, setSuccess] = useState(null); + const [scannerOpen, setScannerOpen] = useState(false); const feeSpeedUserChanged = useRef(false); const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]); + /** + * Interpret a freshly-scanned QR code and stuff it into the recipient + * input. A `bitcoin:bc1q…?sp=sp1q…` BIP-21 URI means "send via silent + * payment if you can; otherwise fall back to the on-chain address" — we + * prefer the `sp` parameter when it parses, and the swap toggle under the + * input lets the user fall back to the on-chain address if they want to. + * Anything else (bare address, `sp1…`, npub, nprofile) is dropped in + * verbatim and `resolveRecipient` does the rest. + */ + const handleScan = useCallback((scanned: string) => { + setScannerOpen(false); + setError(''); + const trimmed = scanned.trim(); + const bip21 = parseBitcoinUri(trimmed); + if (bip21) { + if (bip21.sp && resolveRecipient(bip21.sp)) { + setRecipientInput(bip21.sp); + return; + } + if (bip21.address) { + setRecipientInput(bip21.address); + return; + } + } + setRecipientInput(trimmed); + }, []); + // ── Fee rates ──────────────────────────────────────────────── const { data: feeRates } = useQuery({ queryKey: ['blockbook-fee-rates', blockbookBaseUrl], @@ -533,6 +563,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien // ── Render ─────────────────────────────────────────────────── return ( + <> { if (!v) handleClose(); }}> {t('walletSend.title')} @@ -583,6 +614,8 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien onChange={setRecipientInput} placeholder={t('walletSend.recipient.placeholder')} resolvedPubkey={recipient?.pubkey} + onScanClick={() => setScannerOpen(true)} + scanLabel={t('walletSend.recipient.scan')} /> {recipient && (

@@ -703,6 +736,13 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien )}

+ setScannerOpen(false)} + onScan={handleScan} + title={t('walletSend.recipient.scan')} + /> + ); } diff --git a/src/components/QrScannerDialog.tsx b/src/components/QrScannerDialog.tsx new file mode 100644 index 00000000..6aebdb47 --- /dev/null +++ b/src/components/QrScannerDialog.tsx @@ -0,0 +1,311 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Camera, Loader2, X, ZapOff, Zap } from 'lucide-react'; +import QrScanner from 'qr-scanner'; + +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { cn } from '@/lib/utils'; + +interface QrScannerDialogProps { + isOpen: boolean; + onClose: () => void; + /** Called with the decoded QR text the first time a code is read. */ + onScan: (value: string) => void; + /** Override the dialog title (defaults to the localized "Scan QR code"). */ + title?: string; +} + +/** How long to wait after `start()` resolves before declaring the camera dead. */ +const VIDEO_READY_TIMEOUT_MS = 6000; + +/** + * Camera-based QR scanner dialog. Works in browsers, Capacitor's WKWebView + * (iOS), and Android's WebView, all via `getUserMedia` + the `qr-scanner` + * library (ZXing / BarcodeDetector under the hood). + * + * The dialog owns the camera lifecycle: it spins up the scanner when opened + * and tears it down on close, so callers only need to manage `isOpen` and + * react to `onScan`. + * + * Failure modes we explicitly surface (instead of a silent black screen): + * - Insecure context (HTTP) — getUserMedia is unavailable. + * - Camera permission denied. + * - No camera on the device. + * - `facingMode: 'environment'` not satisfiable (some laptops, some + * locked-down WebViews). We retry with the front camera. + * - `start()` resolves but the video never emits `loadedmetadata` within + * `VIDEO_READY_TIMEOUT_MS` — usually means the worker engine failed to + * initialize or another app is holding the camera. + */ +export function QrScannerDialog({ isOpen, onClose, onScan, title }: QrScannerDialogProps) { + const { t } = useTranslation(); + // Callback ref so we know the moment the element is attached. Radix + // Dialog mounts content lazily inside a Portal, so a plain `useRef` is + // still null on the first effect tick after `isOpen` flips to true. + // A state-backed ref re-runs the effect once the