diff --git a/src/components/BitcoinRecipientInput.tsx b/src/components/BitcoinRecipientInput.tsx index 50a1887d..d7233366 100644 --- a/src/components/BitcoinRecipientInput.tsx +++ b/src/components/BitcoinRecipientInput.tsx @@ -1,99 +1,483 @@ -import { nip19 } from 'nostr-tools'; -import { QrCode } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Bitcoin, EyeOff, QrCode, X } from 'lucide-react'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Input } from '@/components/ui/input'; -import { useAuthor } from '@/hooks/useAuthor'; -import { genUserName } from '@/lib/genUserName'; -import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from '@/components/ui/popover'; +import { QrScannerDialog } from '@/components/QrScannerDialog'; +import { useToast } from '@/hooks/useToast'; +import { parseBitcoinUri, validateBitcoinAddress } from '@/lib/bitcoin'; +import { + isSilentPaymentAddress, + validateSilentPaymentAddress, +} from '@/lib/hdwallet/sp/sender'; import { cn } from '@/lib/utils'; +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * The resolved recipient produced by {@link BitcoinRecipientInput}. + * + * Either a bare on-chain Bitcoin address (`kind === 'address'`) or a BIP-352 + * silent payment address (`kind === 'sp'`). The dialog consumes this shape + * directly when building the PSBT. + */ +export interface ResolvedRecipient { + /** + * For `kind === 'address'`: a validated mainnet on-chain address. + * For `kind === 'sp'`: the `sp1…` string (the real P2TR `P_k` is derived + * at PSBT-build time, after coin selection). + */ + address: string; + /** Recipient kind — determines how the PSBT builder routes the output. */ + kind: 'address' | 'sp'; + /** + * Raw text the user typed / pasted / scanned. Kept so the picker can + * round-trip a chip back into the input on clear if we ever need it + * (currently unused; the chip just dismisses). + */ + raw: string; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + interface BitcoinRecipientInputProps { - value: string; - onChange: (value: string) => void; + /** Currently-selected recipient, or `null` when nothing has been picked. */ + value: ResolvedRecipient | null; + /** Called when the user picks a recipient (from the dropdown / QR scan) or clears. */ + onChange: (value: ResolvedRecipient | null) => void; + /** Input placeholder text. */ placeholder: string; /** - * When set, the resolved Nostr profile (avatar + display name) is rendered - * as a chip below the input. Used when the input value is an `npub1…` / - * `nprofile1…` that the parent has decoded into a hex pubkey. + * Optional initial input value applied when the picker mounts with no + * `value`. Used by callers (e.g. campaign donate flow) that want to + * pre-fill a `bitcoin:…` URI or bare address so the donor only needs to + * pick from the dropdown. + * + * Re-applied each time the value transitions from non-null → null while + * `initialInput` is set, so a "clear chip" inside a prefilled flow + * restores the prefilled text instead of leaving the field empty. */ - 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; + initialInput?: string; } /** - * Plain-text recipient input for the Send Bitcoin dialog. Accepts whatever - * the user types or pastes — Bitcoin addresses, BIP-352 silent-payment - * codes, `npub1…` / `nprofile1…` identifiers, or `bitcoin:` URIs — and - * leaves interpretation to the parent. When `resolvedPubkey` is set, the - * resolved Nostr profile is shown as a chip below the input so the sender - * can confirm the destination. + * Recipient input for the Send Bitcoin dialog. Combines a text input, an + * inline QR-scanner button, and a Radix Popover dropdown that surfaces the + * recognised destination(s) extracted from the input. * - * The optional QR-scan button (controlled by `onScanClick`) is rendered - * inside the input on the right; the parent handles the scanner dialog. + * Recognised destinations: + * + * - Bare on-chain Bitcoin address (any standard mainnet type) → "Send to + * Bitcoin address" row. + * - Bare BIP-352 silent payment address (`sp1…`) → "Send to silent payment + * address" row. + * - `bitcoin:` BIP-21 URI with an on-chain path and/or an `sp=` parameter → + * one row per valid candidate (so a URI carrying both shows two rows and + * the donor picks privacy vs. compatibility). + * + * Clicking a row swaps the input out for a {@link SelectedRecipientChip} via + * `onChange`. Clicking the chip's X button calls `onChange(null)`, which + * returns to the input view. + * + * Anything else (npub, nprofile, free text) is silently ignored — there is + * no account search here, by design. Refocusing or clicking the input while + * it still contains a BIP-21 URI reopens the dropdown so the donor can swap + * between the available options without retyping. */ -export function BitcoinRecipientInput({ value, onChange, placeholder, resolvedPubkey, onScanClick, scanLabel }: BitcoinRecipientInputProps) { +export function BitcoinRecipientInput({ + value, + onChange, + placeholder, + initialInput, +}: BitcoinRecipientInputProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + + // Local input state. Independent of `value` so the user can keep typing + // after dismissing the dropdown without losing their query, and so the + // chip-cleared view starts blank instead of repopulating the previous + // selection. + const [query, setQuery] = useState(initialInput ?? ''); + const [open, setOpen] = useState(false); + const [scannerOpen, setScannerOpen] = useState(false); + const inputRef = useRef(null); + + // Re-apply `initialInput` whenever the picker returns to the input view + // (value !== null → null) AND there is no active query the user might be + // editing. This restores the prefilled URI after a "clear chip" in flows + // like the campaign donate page, without clobbering a fresh edit. + const prevValueRef = useRef(value); + useEffect(() => { + const justCleared = prevValueRef.current !== null && value === null; + if (justCleared && initialInput && query.length === 0) { + setQuery(initialInput); + } + prevValueRef.current = value; + }, [value, initialInput, query]); + + // ── Candidate extraction ────────────────────────────────────────────── + // + // BIP-21 `bitcoin:` URI handling. If the input is a URI, we route the + // same way the QR scanner does: surface every valid candidate as its own + // row so the user explicitly picks privacy (sp) vs. compatibility + // (on-chain). A raw bc1…/sp1… input falls through here unchanged: `bip21` + // is null and the candidate is just the trimmed query. + const trimmed = query.trim(); + const bip21 = useMemo(() => parseBitcoinUri(trimmed), [trimmed]); + + const btcCandidate = useMemo(() => { + const c = bip21 ? bip21.address : trimmed; + if (!c) return ''; + // sp addresses live in spCandidate; don't double-count. + if (isSilentPaymentAddress(c)) return ''; + return validateBitcoinAddress(c) ? c : ''; + }, [bip21, trimmed]); + + const spCandidate = useMemo(() => { + // From the URI: prefer `sp=` if valid; otherwise the path may itself be + // an sp1 address (rare but legal — `bitcoin:sp1…` is just a URI without + // an on-chain fallback). + const c = bip21 ? (bip21.sp ?? bip21.address) : trimmed; + if (!c) return ''; + if (!isSilentPaymentAddress(c)) return ''; + return validateSilentPaymentAddress(c) ? c : ''; + }, [bip21, trimmed]); + + const hasBtc = !!btcCandidate; + const hasSp = !!spCandidate; + const totalItems = (hasSp ? 1 : 0) + (hasBtc ? 1 : 0); + + // Auto-open the dropdown whenever a candidate is available, auto-close on + // empty input. + useEffect(() => { + if (trimmed.length === 0) { + setOpen(false); + return; + } + if (hasSp || hasBtc) setOpen(true); + }, [trimmed, hasSp, hasBtc]); + + // ── Selection callbacks ─────────────────────────────────────────────── + const selectBtc = useCallback( + (address: string) => { + onChange({ address, kind: 'address', raw: query }); + setQuery(''); + setOpen(false); + inputRef.current?.blur(); + }, + [onChange, query], + ); + + const selectSp = useCallback( + (address: string) => { + onChange({ address, kind: 'sp', raw: query }); + setQuery(''); + setOpen(false); + inputRef.current?.blur(); + }, + [onChange, query], + ); + + // ── QR scan handling ────────────────────────────────────────────────── + /** + * Interpret a freshly-scanned QR code. + * + * - **BIP-21 URI with valid bc1 *and* sp1** → drop the URI into the input + * and open the dropdown so the donor picks between them. + * - **BIP-21 URI with only `sp=` valid** → select SP directly (creates + * the chip, bypasses the dropdown). + * - **Bare bitcoin address** → select on-chain directly. + * - **Bare `sp1…` address** → select SP directly. + * - **Anything else** → toast. + */ + const handleScan = useCallback( + (scanned: string) => { + setScannerOpen(false); + const text = scanned.trim(); + const parsed = parseBitcoinUri(text); + + const candidate = parsed ? parsed.address : text; + const sp = parsed?.sp; + + const hasValidBtc = !!candidate && validateBitcoinAddress(candidate); + const hasValidSp = + !!sp && isSilentPaymentAddress(sp) && validateSilentPaymentAddress(sp); + + // Both options — show the dropdown. + if (parsed && hasValidBtc && hasValidSp) { + setQuery(text); + setOpen(true); + // Focus is best-effort; on mobile the scanner dialog dismissal will + // already steal focus and the dropdown stays usable via tap. + inputRef.current?.focus(); + return; + } + + // SP-only via `bitcoin:…?sp=sp1…`. + if (hasValidSp && sp) { + selectSp(sp); + return; + } + + // Direct on-chain. + if (hasValidBtc) { + selectBtc(candidate); + return; + } + + // Bare sp1 (no `bitcoin:` prefix). + if ( + isSilentPaymentAddress(candidate) + && validateSilentPaymentAddress(candidate) + ) { + selectSp(candidate); + return; + } + + toast({ + title: t('walletSend.scanError.title'), + description: t('walletSend.scanError.description'), + variant: 'destructive', + }); + }, + [selectBtc, selectSp, t, toast], + ); + + // ── Chip view ───────────────────────────────────────────────────────── + if (value) { + return ( + onChange(null)} /> + ); + } + + // ── Input + dropdown ────────────────────────────────────────────────── + // + // `popoverOpen` derives from the manual `open` flag AND the presence of + // actionable candidates. This prevents an empty/garbage input from + // popping the dropdown. + const popoverOpen = open && totalItems > 0; + return (
-
- onChange(e.target.value)} - placeholder={placeholder} - autoComplete="off" - spellCheck={false} - className={cn( - 'font-mono text-base md:text-sm', - onScanClick && 'pr-11', - )} - /> - {onScanClick && ( - - )} -
+ + +
+ setQuery(e.target.value)} + // Reopen on focus so a user can recover the dropdown after an + // outside-click dismiss (the value is still in the field). + onFocus={() => { + if (totalItems > 0) setOpen(true); + }} + // `onFocus` only fires on the first tap; subsequent taps while + // the input is still focused need their own opener so the user + // can reopen the choice list without un-focusing first. + onClick={() => { + if (totalItems > 0) setOpen(true); + }} + placeholder={placeholder} + autoComplete="off" + spellCheck={false} + role="combobox" + aria-expanded={popoverOpen} + aria-haspopup="listbox" + aria-autocomplete="list" + className={cn('font-mono text-base md:text-sm pr-11')} + /> + +
+
- {resolvedPubkey && } + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + style={{ width: 'var(--radix-popover-trigger-width)' }} + className="p-0 w-[--radix-popover-trigger-width] max-h-none rounded-xl border border-border bg-popover shadow-lg overflow-hidden" + > +
+ {/* SP comes before BTC so the privacy-preserving option is + the user's first scan target when both are present. */} + {hasSp && ( + + )} + {hasBtc && ( + + )} +
+
+
+ + setScannerOpen(false)} + onScan={handleScan} + title={t('walletSend.recipient.scan')} + />
); } -function ResolvedRecipientPreview({ pubkey }: { pubkey: string }) { - const author = useAuthor(pubkey); - const metadata = author.data?.metadata; - const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey); - const avatarUrl = sanitizeUrl(metadata?.picture); - const encoded = nip19.npubEncode(pubkey); - const fallbackLabel = `${encoded.slice(0, 12)}…${encoded.slice(-8)}`; +// --------------------------------------------------------------------------- +// Dropdown rows +// --------------------------------------------------------------------------- + +/** Truncate long addresses with an ellipsis so they don't overflow the row. */ +function truncateAddress(address: string): string { + return address.length > 28 + ? `${address.slice(0, 14)}…${address.slice(-10)}` + : address; +} + +function BtcAddressRow({ + address, + onClick, +}: { + address: string; + onClick: (address: string) => void; +}) { + const { t } = useTranslation(); + return ( + + ); +} + +/** + * Dropdown row for BIP-352 silent payment addresses. We give it a distinct + * label and icon (privacy eye-off) so the user can tell at a glance that + * this is a static, unlinkable address rather than a regular Bitcoin + * scriptPubKey — the privacy story is materially different. + */ +function SpAddressRow({ + address, + onClick, +}: { + address: string; + onClick: (address: string) => void; +}) { + const { t } = useTranslation(); + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Selected recipient chip +// --------------------------------------------------------------------------- + +/** + * Compact panel that replaces the input once a recipient has been picked. + * Renders a coloured icon (orange Bitcoin / violet EyeOff for SP), the kind + * label, a truncated monospace address, and an X button that clears the + * selection and returns the user to the input view. + */ +function SelectedRecipientChip({ + value, + onClear, +}: { + value: ResolvedRecipient; + onClear: () => void; +}) { + const { t } = useTranslation(); + const { address, kind } = value; + + const displayName = + kind === 'sp' + ? t('walletSend.recipient.silentPayment') + : t('walletSend.recipient.bitcoinAddress'); + const subtitle = truncateAddress(address); return ( -
- - - - {displayName[0]?.toUpperCase() || '?'} - - -
- {displayName} - - {metadata?.nip05?.startsWith('_@') ? metadata.nip05.slice(2) : metadata?.nip05 || fallbackLabel} - +
+ {kind === 'sp' ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ {t('walletSend.recipient.toLabel')} +
+
{displayName}
+
+ {subtitle} +
+
); } diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx index f261ec2b..c01b8a43 100644 --- a/src/components/HDSendBitcoinDialog.tsx +++ b/src/components/HDSendBitcoinDialog.tsx @@ -1,7 +1,6 @@ import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { nip19 } from 'nostr-tools'; import { AlertTriangle, Check, @@ -25,8 +24,10 @@ import { 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 { + BitcoinRecipientInput, + type ResolvedRecipient, +} from '@/components/BitcoinRecipientInput'; import { HelpTip } from '@/components/HelpTip'; import { cn } from '@/lib/utils'; @@ -40,12 +41,7 @@ import { getUniqueBitcoinFeeSpeeds, type BitcoinFeeSpeed, } from '@/lib/bitcoinFeeSpeed'; -import { - isLargeAmount, - nostrPubkeyToBitcoinAddress, - parseBitcoinUri, - satsToUSD, -} from '@/lib/bitcoin'; +import { isLargeAmount, satsToUSD } from '@/lib/bitcoin'; import { broadcastBlockbookTx, fetchFeeRates, @@ -56,7 +52,6 @@ import { type HdInput, type HdSpendableSpUtxo, type HdSpendableUtxo, - parseHdRecipient, previewHdFee, signHdPsbt, } from '@/lib/hdwallet/transaction'; @@ -70,74 +65,6 @@ const USD_PRESETS = [1, 5, 10, 25, 100]; type FeeSpeed = BitcoinFeeSpeed; -// --------------------------------------------------------------------------- -// Recipient resolution -// --------------------------------------------------------------------------- - -interface ResolvedRecipient { - /** - * Final P2TR/P2WPKH/etc. address used as the PSBT output. - * - * For silent-payment (`sp1…`) recipients this is the original `sp1…` - * string — the real on-chain `P_k` is derived at build time, after coin - * selection. The dialog never displays this value directly when - * `kind === 'sp'`; it's kept here so {@link buildHdSpendPsbt} can route - * by recipient kind. - */ - address: string; - /** Optional Nostr pubkey when the recipient was an npub/nprofile. */ - pubkey?: string; - /** Raw text the user typed (for re-display). */ - raw: string; - /** - * Recipient kind. `'address'` for bare Bitcoin addresses (including - * Nostr-derived ones); `'sp'` for BIP-352 silent-payment addresses. - */ - kind: 'address' | 'sp'; -} - -/** - * Parse the recipient input as one of: - * - bare Bitcoin address (mainnet, any standard type) - * - silent-payment address (`sp1…`, mainnet, v0) - * - npub1… → P2TR derived from the Nostr pubkey - * - nprofile1… → P2TR derived from the encoded pubkey - * - * Returns `null` for unparseable input. The caller should treat `null` as - * "input still in progress" rather than "error" until the user submits. - */ -function resolveRecipient(input: string): ResolvedRecipient | null { - const trimmed = input.trim(); - if (!trimmed) return null; - - // Try bare Bitcoin / silent-payment via the unified parser. - const parsed = parseHdRecipient(trimmed); - if (parsed) { - if (parsed.kind === 'address') { - return { address: parsed.address, raw: trimmed, kind: 'address' }; - } - return { address: parsed.spAddress, raw: trimmed, kind: 'sp' }; - } - - // Try NIP-19 npub / nprofile. - if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) { - try { - const decoded = nip19.decode(trimmed); - if (decoded.type === 'npub') { - const address = nostrPubkeyToBitcoinAddress(decoded.data); - if (address) return { address, pubkey: decoded.data, raw: trimmed, kind: 'address' }; - } else if (decoded.type === 'nprofile') { - const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey); - if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed, kind: 'address' }; - } - } catch { - // fall through - } - } - - return null; -} - // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -148,30 +75,20 @@ interface HDSendBitcoinDialogProps { /** BTC/USD price — passed in to avoid duplicate fetches. */ btcPrice?: number; /** - * Optional recipient (bare address, `sp1…` code, npub, or `bitcoin:` URI) - * to prefill the recipient field with when the dialog opens. Used by - * callers like the campaign detail page that already know the - * destination, so the donor only needs to enter an amount. + * Optional initial recipient string to prefill the recipient field. May + * be a bare on-chain address (`bc1…`), a silent payment address + * (`sp1…`), or a `bitcoin:` BIP-21 URI. When the URI carries both an + * on-chain path and an `sp=` parameter, the picker's dropdown surfaces + * both as separate rows so the donor explicitly picks which payment + * path to use (no separate "swap" toggle needed — picking happens in + * the dropdown). * - * The field stays editable — donors can clear and retype if they want - * to send somewhere else. The prefill applies on each open transition - * (false → true) so reopening after a successful send loads the same - * destination again. + * The prefill is re-applied on each open transition (false → true) so + * reopening after a successful send loads the same destination again, + * and is also re-applied when the user clears a previously-selected + * chip so they don't have to retype. */ initialRecipient?: string; - /** - * Optional *alternate* recipient. When supplied alongside - * {@link initialRecipient}, the dialog shows a small "Use silent - * payment instead" / "Use on-chain address instead" toggle under the - * recipient field. Clicking it swaps the input between the two values - * so donors can pick whichever the campaign provides without leaving - * the modal. - * - * Campaign detail page wires the on-chain `bc1…` address as - * {@link initialRecipient} (default) and the silent-payment `sp1…` - * code as `initialRecipientAlt`. - */ - initialRecipientAlt?: string; } interface SendResult { @@ -196,7 +113,7 @@ interface SendResult { * the HD wallet's UTXO set across many addresses, signs with per-input HD-derived * keys, and emits change to a fresh internal address. */ -export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipient, initialRecipientAlt }: HDSendBitcoinDialogProps) { +export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipient }: HDSendBitcoinDialogProps) { const { t } = useTranslation(); const availability = useHdWalletAccess(); const { @@ -224,44 +141,18 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien ); // ── Form state ─────────────────────────────────────────────── - const [recipientInput, setRecipientInput] = useState(''); + // + // The picker owns its input text internally and emits a resolved + // recipient (or null) to us. We only see the final picked destination. + const [recipient, setRecipient] = useState(null); const [usdAmount, setUsdAmount] = useState(5); const [feeSpeed, setFeeSpeed] = useState('halfHour'); 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({ @@ -362,54 +253,26 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien // SP recipients (`sp1…`) produce a fresh, unlinkable Taproot output per // payment — they do NOT have the privacy concern of a reused on-chain // address. The public disclaimer is only needed for bare BTC addresses - // typed in directly (no Nostr identity attached, no SP). - const isRawAddress = - !!recipient && recipient.kind === 'address' && !recipient.pubkey; + // picked from the dropdown (no SP). + const isRawAddress = !!recipient && recipient.kind === 'address'; const [confirmArmed, setConfirmArmed] = useState(false); - // Recipient swap target. When the caller supplied two alternate - // destinations (e.g. campaign detail page passing both `bc1…` and - // `sp1…` from the campaign's wallet endpoints) and the current input - // still matches one of them, expose a one-tap toggle to swap. If the - // donor manually edited the field to something else, the toggle hides - // itself so we don't trash their typed input. - // - // Lives next to `isRawAddress` so the render block can place the - // toggle adjacent to the privacy disclaimer (its natural home — both - // are about whether to expose a reusable on-chain address). - const recipientSwap = useMemo<{ swapTo: string; labelKey: string } | null>(() => { - if (!initialRecipient || !initialRecipientAlt) return null; - const trimmed = recipientInput.trim(); - let swapTo: string | null = null; - if (trimmed === initialRecipient) swapTo = initialRecipientAlt; - else if (trimmed === initialRecipientAlt) swapTo = initialRecipient; - if (!swapTo) return null; - // The label tells the user *what they're switching to* — detect by - // prefix so an `sp1…` swap target advertises silent payment. - const labelKey = swapTo.toLowerCase().startsWith('sp1') - ? 'walletSend.recipient.useSilentPayment' - : 'walletSend.recipient.useOnchain'; - return { swapTo, labelKey }; - }, [initialRecipient, initialRecipientAlt, recipientInput]); - useEffect(() => { setConfirmArmed(false); }, [amountSats, currentFeeRate, btcPrice, recipient?.address]); - // Prefill the recipient field on every dialog open transition (closed → - // open). Callers like the campaign donate column already know the - // destination, so the donor only needs to type an amount. The field - // stays editable; reopening after a send re-applies the prefill instead - // of remembering the cleared value. Tracked via a ref so reopens with a - // stable string still re-apply (the value alone can't tell us whether - // the dialog has reopened). + // Track open transitions so we can re-key the picker on each + // closed → open transition. Re-keying remounts the picker with a fresh + // `initialInput`, restoring the prefilled recipient after a successful + // send (which closes and reopens with the same prefill). + const [openCount, setOpenCount] = useState(0); const wasOpenRef = useRef(false); useEffect(() => { - if (isOpen && !wasOpenRef.current && initialRecipient) { - setRecipientInput(initialRecipient); + if (isOpen && !wasOpenRef.current) { + setOpenCount((c) => c + 1); } wasOpenRef.current = isOpen; - }, [isOpen, initialRecipient]); + }, [isOpen]); const requiresArm = isLarge || isRawAddress; @@ -424,7 +287,6 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien if (!recipient) throw new Error(t('walletSend.errors.enterRecipient')); if (!ownedInputs.length) throw new Error(t('walletSend.errors.noSpendable')); if (!feeRates) throw new Error(t('walletSend.errors.feesNotLoaded')); - if (recipient.pubkey === availability.pubkey) throw new Error(t('walletSend.errors.cantSendToSelf')); if (amountSats <= 0) throw new Error(t('walletSend.errors.enterAmount')); if (insufficient) throw new Error(t('walletSend.errors.insufficient')); @@ -488,7 +350,6 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien setError(t('walletSend.errors.unavailable')); return; } if (!recipient) { setError(t('walletSend.errors.enterRecipient')); return; } - if (recipient.pubkey === availability.pubkey) { setError(t('walletSend.errors.cantSendToSelf')); return; } if (!btcPrice) { setError(t('walletSend.errors.waitingPrice')); return; } if (amountSats <= 0) { setError(t('walletSend.errors.enterAmount')); return; } if (!ownedInputs.length) { setError(t('walletSend.errors.noneYet')); return; } @@ -514,7 +375,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien onClose(); // defer to allow exit animation setTimeout(() => { - setRecipientInput(''); + setRecipient(null); setUsdAmount(5); setError(''); setConfirmArmed(false); @@ -548,7 +409,6 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien // ── Render ─────────────────────────────────────────────────── return ( - <> { if (!v) handleClose(); }}> {t('walletSend.title')} @@ -586,39 +446,26 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien onAmountChangeStart={() => setError('')} /> - {/* Recipient */} + {/* Recipient — text input + Popover dropdown surfacing the + BIP-21 candidates, with an inline QR-scanner button. The + picker swaps itself out for a chip once a destination is + selected; clicking the chip's X returns to the input. */} { + setRecipient(next); + setError(''); + }} placeholder={t('walletSend.recipient.placeholder')} - resolvedPubkey={recipient?.pubkey} - onScanClick={() => setScannerOpen(true)} - scanLabel={t('walletSend.recipient.scan')} + initialInput={initialRecipient} /> - {/* Privacy disclaimer for raw addresses + companion - swap-to-silent-payment toggle. Both are about whether - the donation will land on a reusable, publicly-tied - address; grouping them lets a donor who reads the - warning flip straight to SP without hunting for the - control. When the disclaimer is absent (recipient is - already SP or a Nostr identity), the toggle still - renders so the donor can swap back to on-chain. */} - {(isRawAddress || recipientSwap) && ( -
- {isRawAddress && ( - - )} - {recipientSwap && ( - - )} -
+ {/* Privacy disclaimer for raw on-chain addresses. SP + recipients produce a fresh unlinkable output per payment + and don't need the warning. */} + {isRawAddress && ( + )} {/* Error */} @@ -693,13 +540,6 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien )}
- setScannerOpen(false)} - onScan={handleScan} - title={t('walletSend.recipient.scan')} - /> - ); } diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 509892d3..fb65d843 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -8,6 +8,8 @@ const Popover = PopoverPrimitive.Root const PopoverTrigger = PopoverPrimitive.Trigger +const PopoverAnchor = PopoverPrimitive.Anchor + const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -32,4 +34,4 @@ const PopoverContent = React.forwardRef< }) PopoverContent.displayName = PopoverPrimitive.Content.displayName -export { Popover, PopoverTrigger, PopoverContent } +export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent } diff --git a/src/locales/ar.json b/src/locales/ar.json index 543bcce5..b2aa667f 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -1047,9 +1047,13 @@ "notEnoughBitcoin": "لا يوجد بيتكوين كافٍ", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "استخدام الدفع الصامت بدلاً من ذلك", - "useOnchain": "استخدام عنوان على السلسلة بدلاً من ذلك", - "scan": "مسح رمز QR" + "scan": "مسح رمز QR", + "sendToOnchain": "الإرسال إلى عنوان بيتكوين", + "sendToSilentPayment": "الإرسال إلى عنوان دفع صامت", + "bitcoinAddress": "عنوان بيتكوين", + "silentPayment": "عنوان دفع صامت", + "toLabel": "إلى", + "clear": "مسح المستلم" }, "feeSpeed": { "fastest": "~10 دقائق", @@ -1065,10 +1069,9 @@ }, "errors": { "unavailable": "محفظة HD غير متاحة لنوع تسجيل الدخول هذا.", - "enterRecipient": "أدخل عنوان بيتكوين أو عنوان sp1… أو npub.", + "enterRecipient": "أدخل عنوان بيتكوين أو عنوان دفع صامت sp1….", "noSpendable": "لا يوجد بيتكوين قابل للإنفاق في هذه المحفظة.", "feesNotLoaded": "لم يتم تحميل معدلات الرسوم.", - "cantSendToSelf": "لا يمكنك الإرسال إلى نفسك.", "enterAmount": "أدخل مبلغاً.", "insufficient": "البيتكوين غير كافٍ لهذا المبلغ + رسوم الشبكة.", "waitingPrice": "في انتظار سعر BTC…", @@ -1077,6 +1080,10 @@ "toast": { "failedTitle": "فشلت المعاملة" }, + "scanError": { + "title": "تعذّر قراءة رمز QR هذا", + "description": "يُتوقّع عنوان بيتكوين، أو عنوان دفع صامت (sp1…)، أو رابط bitcoin:." + }, "success": { "title": "تم إرسال البيتكوين", "satsAmount": "{{sats}} ساتوشي", diff --git a/src/locales/en.json b/src/locales/en.json index 878f8938..468cda5d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1487,9 +1487,13 @@ "satPerVB": "{{rate}} sat/vB", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "Use silent payment instead", - "useOnchain": "Use on-chain address instead", - "scan": "Scan QR code" + "scan": "Scan QR code", + "sendToOnchain": "Send to Bitcoin address", + "sendToSilentPayment": "Send to silent payment address", + "bitcoinAddress": "Bitcoin address", + "silentPayment": "Silent payment address", + "toLabel": "To", + "clear": "Clear recipient" }, "feeSpeed": { "fastest": "~10 min", @@ -1505,15 +1509,18 @@ }, "errors": { "unavailable": "HD wallet is not available for this login type.", - "enterRecipient": "Enter a Bitcoin address, sp1… address, or npub.", + "enterRecipient": "Enter a Bitcoin address or sp1… silent payment address.", "noSpendable": "No spendable Bitcoin in this wallet.", "feesNotLoaded": "Fee rates not loaded.", - "cantSendToSelf": "You can't send to yourself.", "enterAmount": "Enter an amount.", "insufficient": "Not enough Bitcoin for this amount + network fee.", "waitingPrice": "Waiting for BTC price…", "noneYet": "You don't have any Bitcoin yet." }, + "scanError": { + "title": "Couldn't read that QR code", + "description": "Expected a Bitcoin address, a silent payment address (sp1…), or a bitcoin: URI." + }, "toast": { "failedTitle": "Transaction failed" }, diff --git a/src/locales/es.json b/src/locales/es.json index 694d7191..7b8ff48d 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1063,9 +1063,13 @@ "notEnoughBitcoin": "No hay suficiente Bitcoin", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "Usar pago silencioso en su lugar", - "useOnchain": "Usar dirección en cadena en su lugar", - "scan": "Escanear código QR" + "scan": "Escanear código QR", + "sendToOnchain": "Enviar a dirección de Bitcoin", + "sendToSilentPayment": "Enviar a dirección de pago silencioso", + "bitcoinAddress": "Dirección de Bitcoin", + "silentPayment": "Dirección de pago silencioso", + "toLabel": "Para", + "clear": "Borrar destinatario" }, "feeSpeed": { "fastest": "~10 min", @@ -1081,15 +1085,18 @@ }, "errors": { "unavailable": "La cartera HD no está disponible para este tipo de inicio de sesión.", - "enterRecipient": "Introduce una dirección de Bitcoin, una dirección sp1… o un npub.", + "enterRecipient": "Introduce una dirección de Bitcoin o una dirección de pago silencioso sp1….", "noSpendable": "No hay Bitcoin gastable en esta cartera.", "feesNotLoaded": "No se cargaron las tasas de comisión.", - "cantSendToSelf": "No puedes enviarte a ti mismo.", "enterAmount": "Introduce una cantidad.", "insufficient": "No hay Bitcoin suficiente para esta cantidad + la comisión de red.", "waitingPrice": "Esperando el precio del BTC…", "noneYet": "Aún no tienes Bitcoin." }, + "scanError": { + "title": "No se pudo leer ese código QR", + "description": "Se esperaba una dirección de Bitcoin, una dirección de pago silencioso (sp1…) o un URI bitcoin:." + }, "toast": { "failedTitle": "La transacción falló" }, diff --git a/src/locales/fa.json b/src/locales/fa.json index bbd4c3e6..31ebe372 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1063,9 +1063,13 @@ "notEnoughBitcoin": "بیت‌کوین کافی نیست", "recipient": { "placeholder": "bc1…، sp1…", - "useSilentPayment": "استفاده از پرداخت خاموش به جای آن", - "useOnchain": "استفاده از نشانی روی‌زنجیره به جای آن", - "scan": "اسکن کد QR" + "scan": "اسکن کد QR", + "sendToOnchain": "ارسال به نشانی بیت‌کوین", + "sendToSilentPayment": "ارسال به نشانی پرداخت خاموش", + "bitcoinAddress": "نشانی بیت‌کوین", + "silentPayment": "نشانی پرداخت خاموش", + "toLabel": "به", + "clear": "پاک کردن گیرنده" }, "feeSpeed": { "fastest": "~۱۰ دقیقه", @@ -1081,10 +1085,9 @@ }, "errors": { "unavailable": "کیف پول HD برای این روش ورود در دسترس نیست.", - "enterRecipient": "یک نشانی بیت‌کوین، نشانی sp1… یا npub وارد کنید.", + "enterRecipient": "یک نشانی بیت‌کوین یا نشانی پرداخت خاموش sp1… وارد کنید.", "noSpendable": "هیچ بیت‌کوین قابل خرجی در این کیف پول وجود ندارد.", "feesNotLoaded": "نرخ کارمزد بارگذاری نشده است.", - "cantSendToSelf": "نمی‌توانید به خودتان ارسال کنید.", "enterAmount": "یک مبلغ وارد کنید.", "insufficient": "بیت‌کوین کافی برای این مبلغ + کارمزد شبکه ندارید.", "waitingPrice": "در انتظار قیمت BTC…", @@ -1098,6 +1101,10 @@ "satsAmount": "{{sats}} ساتوشی", "viewTransaction": "مشاهدهٔ تراکنش", "done": "انجام شد" + }, + "scanError": { + "title": "خواندن آن کد QR ممکن نشد", + "description": "یک نشانی بیت‌کوین، نشانی پرداخت خاموش (sp1…) یا URI با پیشوند bitcoin: انتظار می‌رفت." } }, "qrScanner": { diff --git a/src/locales/fr.json b/src/locales/fr.json index 4566494f..396c47d1 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1485,9 +1485,13 @@ "notEnoughBitcoin": "Bitcoin insuffisant", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "Utiliser un paiement silencieux à la place", - "useOnchain": "Utiliser une adresse on-chain à la place", - "scan": "Scanner le QR code" + "scan": "Scanner le QR code", + "sendToOnchain": "Envoyer à une adresse Bitcoin", + "sendToSilentPayment": "Envoyer à une adresse de paiement silencieux", + "bitcoinAddress": "Adresse Bitcoin", + "silentPayment": "Adresse de paiement silencieux", + "toLabel": "À", + "clear": "Effacer le destinataire" }, "feeSpeed": { "fastest": "~10 min", @@ -1503,15 +1507,18 @@ }, "errors": { "unavailable": "Le portefeuille HD n'est pas disponible pour ce type de connexion.", - "enterRecipient": "Saisissez une adresse Bitcoin, une adresse sp1…, ou un npub.", + "enterRecipient": "Saisissez une adresse Bitcoin ou une adresse de paiement silencieux sp1….", "noSpendable": "Pas de Bitcoin dépensable dans ce portefeuille.", "feesNotLoaded": "Taux de frais non chargés.", - "cantSendToSelf": "Vous ne pouvez pas vous envoyer à vous-même.", "enterAmount": "Saisissez un montant.", "insufficient": "Pas assez de Bitcoin pour ce montant + frais réseau.", "waitingPrice": "En attente du prix BTC…", "noneYet": "Vous n'avez pas encore de Bitcoin." }, + "scanError": { + "title": "Impossible de lire ce QR code", + "description": "Une adresse Bitcoin, une adresse de paiement silencieux (sp1…), ou un URI bitcoin: était attendu." + }, "toast": { "failedTitle": "Échec de la transaction" }, diff --git a/src/locales/hi.json b/src/locales/hi.json index 5e3b6c82..2b481b72 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -1431,9 +1431,13 @@ "notEnoughBitcoin": "पर्याप्त बिटकॉइन नहीं", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "इसके बजाय साइलेंट पेमेंट का उपयोग करें", - "useOnchain": "इसके बजाय ऑन-चेन एड्रेस का उपयोग करें", - "scan": "QR कोड स्कैन करें" + "scan": "QR कोड स्कैन करें", + "sendToOnchain": "Bitcoin एड्रेस पर भेजें", + "sendToSilentPayment": "साइलेंट पेमेंट एड्रेस पर भेजें", + "bitcoinAddress": "Bitcoin एड्रेस", + "silentPayment": "साइलेंट पेमेंट एड्रेस", + "toLabel": "किसे", + "clear": "प्राप्तकर्ता हटाएँ" }, "feeSpeed": { "fastest": "~10 मिनट", @@ -1449,10 +1453,9 @@ }, "errors": { "unavailable": "इस लॉगिन प्रकार के लिए HD वॉलेट उपलब्ध नहीं है।", - "enterRecipient": "एक Bitcoin एड्रेस, sp1… एड्रेस, या npub दर्ज करें।", + "enterRecipient": "एक Bitcoin एड्रेस या sp1… साइलेंट पेमेंट एड्रेस दर्ज करें।", "noSpendable": "इस वॉलेट में कोई ख़र्च-योग्य Bitcoin नहीं।", "feesNotLoaded": "Fee rates लोड नहीं हुईं।", - "cantSendToSelf": "आप ख़ुद को नहीं भेज सकते।", "enterAmount": "एक राशि दर्ज करें।", "insufficient": "इस राशि + नेटवर्क फ़ीस के लिए Bitcoin पर्याप्त नहीं।", "waitingPrice": "BTC दाम का इंतज़ार है…", @@ -1461,6 +1464,10 @@ "toast": { "failedTitle": "Transaction विफल" }, + "scanError": { + "title": "वह QR कोड पढ़ा नहीं जा सका", + "description": "एक Bitcoin एड्रेस, साइलेंट पेमेंट एड्रेस (sp1…), या bitcoin: URI अपेक्षित था।" + }, "success": { "title": "Bitcoin भेज दिया", "satsAmount": "{{sats}} sats", diff --git a/src/locales/id.json b/src/locales/id.json index 4991e43d..a7a5ade8 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -1431,9 +1431,13 @@ "notEnoughBitcoin": "Bitcoin tidak mencukupi", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "Gunakan silent payment saja", - "useOnchain": "Gunakan alamat on-chain saja", - "scan": "Pindai kode QR" + "scan": "Pindai kode QR", + "sendToOnchain": "Kirim ke alamat Bitcoin", + "sendToSilentPayment": "Kirim ke alamat silent payment", + "bitcoinAddress": "Alamat Bitcoin", + "silentPayment": "Alamat silent payment", + "toLabel": "Kepada", + "clear": "Hapus penerima" }, "feeSpeed": { "fastest": "~10 menit", @@ -1449,15 +1453,18 @@ }, "errors": { "unavailable": "Dompet HD tidak tersedia untuk jenis login ini.", - "enterRecipient": "Masukkan alamat Bitcoin, alamat sp1…, atau npub.", + "enterRecipient": "Masukkan alamat Bitcoin atau alamat silent payment sp1….", "noSpendable": "Tidak ada Bitcoin yang bisa dibelanjakan di dompet ini.", "feesNotLoaded": "Tarif biaya belum dimuat.", - "cantSendToSelf": "Anda tidak bisa mengirim ke diri sendiri.", "enterAmount": "Masukkan jumlah.", "insufficient": "Bitcoin tidak cukup untuk jumlah ini + biaya jaringan.", "waitingPrice": "Menunggu harga BTC…", "noneYet": "Anda belum punya Bitcoin." }, + "scanError": { + "title": "Tidak bisa membaca kode QR itu", + "description": "Diharapkan alamat Bitcoin, alamat silent payment (sp1…), atau URI bitcoin:." + }, "toast": { "failedTitle": "Transaksi gagal" }, diff --git a/src/locales/km.json b/src/locales/km.json index e29fcf13..896e00e7 100644 --- a/src/locales/km.json +++ b/src/locales/km.json @@ -1063,9 +1063,13 @@ "notEnoughBitcoin": "មិនមាន Bitcoin គ្រប់គ្រាន់", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "ប្រើការទូទាត់ស្ងាត់ជំនួសវិញ", - "useOnchain": "ប្រើអាសយដ្ឋានលើខ្សែសង្វាក់ជំនួសវិញ", - "scan": "ស្កេនកូដ QR" + "scan": "ស្កេនកូដ QR", + "sendToOnchain": "ផ្ញើទៅអាសយដ្ឋាន Bitcoin", + "sendToSilentPayment": "ផ្ញើទៅអាសយដ្ឋានទូទាត់ស្ងាត់", + "bitcoinAddress": "អាសយដ្ឋាន Bitcoin", + "silentPayment": "អាសយដ្ឋានទូទាត់ស្ងាត់", + "toLabel": "ជូន", + "clear": "សម្អាតអ្នកទទួល" }, "feeSpeed": { "fastest": "~១០ នាទី", @@ -1081,15 +1085,18 @@ }, "errors": { "unavailable": "កាបូបលុយ HD មិនអាចប្រើបានសម្រាប់ប្រភេទចូលនេះទេ។", - "enterRecipient": "បញ្ចូលអាសយដ្ឋាន Bitcoin, អាសយដ្ឋាន sp1… ឬ npub។", + "enterRecipient": "បញ្ចូលអាសយដ្ឋាន Bitcoin ឬអាសយដ្ឋានទូទាត់ស្ងាត់ sp1…។", "noSpendable": "គ្មាន Bitcoin ដែលអាចចំណាយបាននៅក្នុងកាបូបលុយនេះទេ។", "feesNotLoaded": "អត្រាថ្លៃមិនបានផ្ទុក។", - "cantSendToSelf": "អ្នកមិនអាចផ្ញើទៅខ្លួនឯងបានទេ។", "enterAmount": "បញ្ចូលចំនួន។", "insufficient": "មិនមាន Bitcoin គ្រប់គ្រាន់សម្រាប់ចំនួននេះ + ថ្លៃបណ្តាញ។", "waitingPrice": "កំពុងរង់ចាំតម្លៃ BTC…", "noneYet": "អ្នកមិនទាន់មាន Bitcoin ទេ។" }, + "scanError": { + "title": "មិនអាចអានកូដ QR នេះបានទេ", + "description": "រំពឹងថានឹងមានអាសយដ្ឋាន Bitcoin, អាសយដ្ឋានទូទាត់ស្ងាត់ (sp1…) ឬ URI bitcoin:។" + }, "toast": { "failedTitle": "ប្រតិបត្តិការបរាជ័យ" }, diff --git a/src/locales/ps.json b/src/locales/ps.json index dfa8d77d..bfc3ee1c 100644 --- a/src/locales/ps.json +++ b/src/locales/ps.json @@ -1063,9 +1063,13 @@ "notEnoughBitcoin": "کافی بټکوین نشته", "recipient": { "placeholder": "bc1…، sp1…", - "useSilentPayment": "پر ځای يې د چوپې ورکړې وکاروئ", - "useOnchain": "پر ځای يې د زنځیر په پته وکاروئ", - "scan": "د QR کوډ سکن کړئ" + "scan": "د QR کوډ سکن کړئ", + "sendToOnchain": "د بټکوین پتې ته ولیږئ", + "sendToSilentPayment": "د چوپې ورکړې پتې ته ولیږئ", + "bitcoinAddress": "د بټکوین پته", + "silentPayment": "د چوپې ورکړې پته", + "toLabel": "ته", + "clear": "ترلاسه‌کوونکی پاک کړئ" }, "feeSpeed": { "fastest": "~۱۰ دقیقې", @@ -1081,15 +1085,18 @@ }, "errors": { "unavailable": "د دې ډول ننوتلو لپاره د HD والټ شته نه دی.", - "enterRecipient": "د بټکوین پته، د sp1… پته، یا npub دننه کړئ.", + "enterRecipient": "د بټکوین پته یا د sp1… چوپې ورکړې پته دننه کړئ.", "noSpendable": "په دې والټ کې د لګولو وړ بټکوین نشته.", "feesNotLoaded": "د فیس نرخونه نه دي لوډ شوي.", - "cantSendToSelf": "تاسو ځان ته نه شئ لیږلی.", "enterAmount": "یوه اندازه دننه کړئ.", "insufficient": "د دې اندازې او د شبکې فیس لپاره کافي بټکوین نشته.", "waitingPrice": "د BTC قیمت ته انتظار…", "noneYet": "تاسو لاهم هېڅ بټکوین نه لرئ." }, + "scanError": { + "title": "هغه QR کوډ ونه لوستل شو", + "description": "د بټکوین پته، د چوپې ورکړې پته (sp1…)، یا د bitcoin: URI تمه کېده." + }, "toast": { "failedTitle": "معامله ناکامه شوه" }, diff --git a/src/locales/pt.json b/src/locales/pt.json index a14effc6..aefb278d 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -1495,9 +1495,13 @@ "notEnoughBitcoin": "Bitcoin insuficiente", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "Usar pagamento silencioso", - "useOnchain": "Usar endereço on-chain", - "scan": "Escanear código QR" + "scan": "Escanear código QR", + "sendToOnchain": "Enviar para endereço Bitcoin", + "sendToSilentPayment": "Enviar para endereço de pagamento silencioso", + "bitcoinAddress": "Endereço Bitcoin", + "silentPayment": "Endereço de pagamento silencioso", + "toLabel": "Para", + "clear": "Limpar destinatário" }, "feeSpeed": { "fastest": "~10 min", @@ -1513,10 +1517,9 @@ }, "errors": { "unavailable": "Carteira HD não está disponível para este tipo de login.", - "enterRecipient": "Digite um endereço Bitcoin, endereço sp1… ou npub.", + "enterRecipient": "Digite um endereço Bitcoin ou endereço de pagamento silencioso sp1….", "noSpendable": "Sem Bitcoin gastável nesta carteira.", "feesNotLoaded": "Taxas não carregadas.", - "cantSendToSelf": "Você não pode enviar para si mesmo.", "enterAmount": "Digite um valor.", "insufficient": "Bitcoin insuficiente para este valor + taxa de rede.", "waitingPrice": "Aguardando preço do BTC…", @@ -1525,6 +1528,10 @@ "toast": { "failedTitle": "Transação falhou" }, + "scanError": { + "title": "Não foi possível ler esse código QR", + "description": "Era esperado um endereço Bitcoin, um endereço de pagamento silencioso (sp1…) ou uma URI bitcoin:." + }, "success": { "title": "Bitcoin enviado", "satsAmount": "{{sats}} sats", diff --git a/src/locales/ru.json b/src/locales/ru.json index 525b4169..0d0b2ad5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1495,9 +1495,13 @@ "notEnoughBitcoin": "Недостаточно биткоинов", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "Использовать тихий платёж", - "useOnchain": "Использовать адрес в блокчейне", - "scan": "Сканировать QR-код" + "scan": "Сканировать QR-код", + "sendToOnchain": "Отправить на Bitcoin-адрес", + "sendToSilentPayment": "Отправить на адрес тихого платежа", + "bitcoinAddress": "Bitcoin-адрес", + "silentPayment": "Адрес тихого платежа", + "toLabel": "Кому", + "clear": "Очистить получателя" }, "feeSpeed": { "fastest": "~10 мин", @@ -1513,15 +1517,18 @@ }, "errors": { "unavailable": "HD-кошелёк недоступен для этого типа входа.", - "enterRecipient": "Введите Bitcoin-адрес, адрес sp1… или npub.", + "enterRecipient": "Введите Bitcoin-адрес или адрес тихого платежа sp1…", "noSpendable": "В этом кошельке нет тратимого Bitcoin.", "feesNotLoaded": "Ставки комиссий не загружены.", - "cantSendToSelf": "Вы не можете отправить самому себе.", "enterAmount": "Введите сумму.", "insufficient": "Недостаточно Bitcoin для этой суммы + комиссии сети.", "waitingPrice": "Ожидание цены BTC…", "noneYet": "У вас пока нет Bitcoin." }, + "scanError": { + "title": "Не удалось прочитать этот QR-код", + "description": "Ожидался Bitcoin-адрес, адрес тихого платежа (sp1…) или bitcoin: URI." + }, "toast": { "failedTitle": "Транзакция не удалась" }, diff --git a/src/locales/sn.json b/src/locales/sn.json index b10c5957..3dd75bbc 100644 --- a/src/locales/sn.json +++ b/src/locales/sn.json @@ -1063,9 +1063,13 @@ "notEnoughBitcoin": "Bitcoin haina kukwana", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "Shandisa mubhadharo wakanyararira pachinzvimbo", - "useOnchain": "Shandisa kero yepacheni pachinzvimbo", - "scan": "Skena kodhi yeQR" + "scan": "Skena kodhi yeQR", + "sendToOnchain": "Tumira kukero yeBitcoin", + "sendToSilentPayment": "Tumira kukero yemubhadharo wakanyararira", + "bitcoinAddress": "Kero yeBitcoin", + "silentPayment": "Kero yemubhadharo wakanyararira", + "toLabel": "Kuna", + "clear": "Bvisa mugamuchiri" }, "feeSpeed": { "fastest": "~10 maminitsi", @@ -1081,10 +1085,9 @@ }, "errors": { "unavailable": "Chikwama cheHD hachiwanike pamhando yekupinda iyi.", - "enterRecipient": "Isa kero yeBitcoin, kero ye-sp1… kana npub.", + "enterRecipient": "Isa kero yeBitcoin kana kero yemubhadharo wakanyararira ye-sp1….", "noSpendable": "Hapana Bitcoin inogona kushandiswa muchikwama ichi.", "feesNotLoaded": "Migwagwa yemiripo haina kuloadwa.", - "cantSendToSelf": "Haukwanise kutumira kwauri.", "enterAmount": "Isa huwandu.", "insufficient": "Bitcoin haina kukwana pahuwandu uhwu + muripo wenetwork.", "waitingPrice": "Kumirira mutengo weBTC…", @@ -1098,6 +1101,10 @@ "satsAmount": "{{sats}} sats", "viewTransaction": "Tarisa chinoitwa", "done": "Zvapera" + }, + "scanError": { + "title": "Hatina kukwanisa kuverenga kodhi yeQR iyoyo", + "description": "Yakanga yakatarisirwa kero yeBitcoin, kero yemubhadharo wakanyararira (sp1…), kana bitcoin: URI." } }, "qrScanner": { diff --git a/src/locales/sw.json b/src/locales/sw.json index 72518438..2d28bf38 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -1390,9 +1390,13 @@ "notEnoughBitcoin": "Bitcoin haitoshi", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "Tumia malipo ya kimya badala yake", - "useOnchain": "Tumia anwani ya katika-mnyororo badala yake", - "scan": "Changanua msimbo wa QR" + "scan": "Changanua msimbo wa QR", + "sendToOnchain": "Tuma kwa anwani ya Bitcoin", + "sendToSilentPayment": "Tuma kwa anwani ya malipo ya kimya", + "bitcoinAddress": "Anwani ya Bitcoin", + "silentPayment": "Anwani ya malipo ya kimya", + "toLabel": "Kwa", + "clear": "Futa mpokeaji" }, "feeSpeed": { "fastest": "~dakika 10", @@ -1408,15 +1412,18 @@ }, "errors": { "unavailable": "Pochi ya HD haipatikani kwa aina hii ya kuingia.", - "enterRecipient": "Weka anwani ya Bitcoin, anwani ya sp1…, au npub.", + "enterRecipient": "Weka anwani ya Bitcoin au anwani ya malipo ya kimya ya sp1….", "noSpendable": "Hakuna Bitcoin inayoweza kutumika katika pochi hii.", "feesNotLoaded": "Viwango vya ada havijapakiwa.", - "cantSendToSelf": "Huwezi kujitumia mwenyewe.", "enterAmount": "Weka kiasi.", "insufficient": "Hakuna Bitcoin ya kutosha kwa kiasi hiki + ada ya mtandao.", "waitingPrice": "Inasubiri bei ya BTC…", "noneYet": "Bado huna Bitcoin yoyote." }, + "scanError": { + "title": "Imeshindwa kusoma msimbo huo wa QR", + "description": "Ilitarajiwa anwani ya Bitcoin, anwani ya malipo ya kimya (sp1…), au URI ya bitcoin:." + }, "toast": { "failedTitle": "Muamala umeshindikana" }, diff --git a/src/locales/tr.json b/src/locales/tr.json index e4a29167..4acf70b3 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -25,7 +25,7 @@ "donors_one": "{{count}} bağışçı", "donors_other": "{{count}} bağışçı", "clearSearch": "Aramayı temizle", - "searching": "Aranıyor\u2026", + "searching": "Aranıyor…", "searchResultsCount_one": "{{count}} sonuç", "searchResultsCount_other": "{{count}} sonuç", "sortAriaLabel": "Sıralama düzeni", @@ -620,9 +620,9 @@ "emptyTitle": "Henüz taahhüt yok", "emptyHint": "Taahhüt oluşturan ilk kişi olun.", "emptyHintCountry": "{{country}} için taahhüt oluşturan ilk kişi olun.", - "searchPlaceholder": "Taahhüt ara\u2026", + "searchPlaceholder": "Taahhüt ara…", "searchAriaLabel": "Taahhütleri ara", - "noMatch": "\u201c{{query}}\u201d ile eşleşen taahhüt yok", + "noMatch": "“{{query}}” ile eşleşen taahhüt yok", "noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin.", "needsReview": "İncelemeyi bekliyor", "needsReviewDesc": "Henüz öne çıkarılmamış veya gizlenmemiş {{appName}} taahhütleri. Birini Öne Çıkan rafına yükseltin ya da Gizle ile gizleyin.", @@ -754,9 +754,9 @@ "tickerFeaturedGroups_other": "Nostr'da öne çıkan grup", "tickerCountries_one": "ülke bugün paylaşım yapıyor", "tickerCountries_other": "ülke bugün paylaşım yapıyor", - "searchPlaceholder": "Grup ara\u2026", + "searchPlaceholder": "Grup ara…", "searchAriaLabel": "Grupları ara", - "noMatch": "\u201c{{query}}\u201d ile eşleşen grup yok", + "noMatch": "“{{query}}” ile eşleşen grup yok", "noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin." }, "create": { @@ -1090,9 +1090,9 @@ "yourCampaignsDesc": "Kampanyalarınız Nostr'da yayında ve bağışlar kampanya bağlantısıyla çalışır. Bir Team Soapbox moderatörü onayladığında ana sayfada görünürler.", "empty": "Henüz kampanya yok", "emptyHint": "{{appName}}'da fon toplama kampanyası başlatan ilk kişi olun. Hikayenizi anlatın, yararlanıcılarınızı seçin ve bağlantıyı paylaşın.", - "searchPlaceholder": "Kampanya ara\u2026", + "searchPlaceholder": "Kampanya ara…", "searchAriaLabel": "Kampanyaları ara", - "noMatch": "\u201c{{query}}\u201d ile eşleşen kampanya yok", + "noMatch": "“{{query}}” ile eşleşen kampanya yok", "noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin." }, "all": { @@ -1430,9 +1430,13 @@ "notEnoughBitcoin": "Yetersiz Bitcoin", "recipient": { "placeholder": "bc1…, sp1…", - "useSilentPayment": "Bunun yerine sessiz ödeme kullan", - "useOnchain": "Bunun yerine zincir üstü adres kullan", - "scan": "QR kodu tara" + "scan": "QR kodu tara", + "sendToOnchain": "Bitcoin adresine gönder", + "sendToSilentPayment": "Sessiz ödeme adresine gönder", + "bitcoinAddress": "Bitcoin adresi", + "silentPayment": "Sessiz ödeme adresi", + "toLabel": "Alıcı", + "clear": "Alıcıyı temizle" }, "feeSpeed": { "fastest": "~10 dk", @@ -1448,10 +1452,9 @@ }, "errors": { "unavailable": "HD cüzdan bu giriş türü için kullanılamıyor.", - "enterRecipient": "Bir Bitcoin adresi, sp1… adresi ya da npub girin.", + "enterRecipient": "Bir Bitcoin adresi ya da sp1… sessiz ödeme adresi girin.", "noSpendable": "Bu cüzdanda harcanabilir Bitcoin yok.", "feesNotLoaded": "Ücret oranları yüklenmedi.", - "cantSendToSelf": "Kendinize gönderim yapamazsınız.", "enterAmount": "Bir tutar girin.", "insufficient": "Bu tutar + ağ ücreti için yeterli Bitcoin yok.", "waitingPrice": "BTC fiyatı bekleniyor…", @@ -1465,6 +1468,10 @@ "satsAmount": "{{sats}} sat", "viewTransaction": "İşlemi görüntüle", "done": "Tamam" + }, + "scanError": { + "title": "Bu QR kodu okunamadı", + "description": "Bir Bitcoin adresi, sessiz ödeme adresi (sp1…) veya bitcoin: URI'si bekleniyordu." } }, "qrScanner": { diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json index a76ba392..86ce8533 100644 --- a/src/locales/zh-Hant.json +++ b/src/locales/zh-Hant.json @@ -999,9 +999,13 @@ "notEnoughBitcoin": "比特幣不足", "recipient": { "placeholder": "bc1…、sp1…", - "useSilentPayment": "改用靜默支付", - "useOnchain": "改用鏈上地址", - "scan": "掃描 QR 碼" + "scan": "掃描 QR 碼", + "sendToOnchain": "傳送至比特幣地址", + "sendToSilentPayment": "傳送至靜默支付地址", + "bitcoinAddress": "比特幣地址", + "silentPayment": "靜默支付地址", + "toLabel": "收件人", + "clear": "清除收件人" }, "feeSpeed": { "fastest": "~10 分鐘", @@ -1017,15 +1021,18 @@ }, "errors": { "unavailable": "當前登入方式不支援 HD 錢包。", - "enterRecipient": "請輸入比特幣地址、sp1… 地址或 npub。", + "enterRecipient": "請輸入比特幣地址或 sp1… 靜默支付地址。", "noSpendable": "此錢包中沒有可花費的比特幣。", "feesNotLoaded": "費率未載入。", - "cantSendToSelf": "不能向自己傳送。", "enterAmount": "請輸入金額。", "insufficient": "比特幣不足以支付該金額和網路費用。", "waitingPrice": "正在等待 BTC 價格…", "noneYet": "你還沒有任何比特幣。" }, + "scanError": { + "title": "無法讀取該 QR 碼", + "description": "預期為比特幣地址、靜默支付地址(sp1…)或 bitcoin: URI。" + }, "toast": { "failedTitle": "交易失敗" }, diff --git a/src/locales/zh.json b/src/locales/zh.json index 41e521df..da18152a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1063,9 +1063,13 @@ "notEnoughBitcoin": "比特币不足", "recipient": { "placeholder": "bc1…、sp1…", - "useSilentPayment": "改用静默支付", - "useOnchain": "改用链上地址", - "scan": "扫描二维码" + "scan": "扫描二维码", + "sendToOnchain": "发送到比特币地址", + "sendToSilentPayment": "发送到静默支付地址", + "bitcoinAddress": "比特币地址", + "silentPayment": "静默支付地址", + "toLabel": "收件人", + "clear": "清除收件人" }, "feeSpeed": { "fastest": "~10 分钟", @@ -1081,15 +1085,18 @@ }, "errors": { "unavailable": "当前登录方式不支持 HD 钱包。", - "enterRecipient": "请输入比特币地址、sp1… 地址或 npub。", + "enterRecipient": "请输入比特币地址或 sp1… 静默支付地址。", "noSpendable": "此钱包中没有可花费的比特币。", "feesNotLoaded": "费率未加载。", - "cantSendToSelf": "不能向自己发送。", "enterAmount": "请输入金额。", "insufficient": "比特币不足以支付该金额和网络费用。", "waitingPrice": "正在等待 BTC 价格…", "noneYet": "你还没有任何比特币。" }, + "scanError": { + "title": "无法读取该二维码", + "description": "需要的是比特币地址、静默支付地址(sp1…)或 bitcoin: URI。" + }, "toast": { "failedTitle": "交易失败" }, diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index 5b20d426..533e23f4 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -1214,8 +1214,17 @@ function DonateColumn({ isOpen={sendOpen} onClose={() => setSendOpen(false)} btcPrice={btcPrice} - initialRecipient={campaign.wallets.onchain.value} - initialRecipientAlt={campaign.wallets.sp?.value} + /* When the campaign exposes both an on-chain address and a + silent-payment code, prefill with a combined `bitcoin:` + BIP-21 URI so the picker's dropdown surfaces both rows and + the donor explicitly picks privacy vs. compatibility. + Otherwise prefill with the single address; the picker + accepts bare `bc1…` / `sp1…` inputs directly. */ + initialRecipient={ + campaign.wallets.sp?.value + ? `bitcoin:${campaign.wallets.onchain.value}?sp=${campaign.wallets.sp.value}` + : campaign.wallets.onchain.value + } /> )}