Pick BIP-21 destination from a dropdown in Send dialog

When a scanned QR or pasted BIP-21 URI carries both an on-chain address
and an sp= silent-payment parameter, the recipient input now surfaces
both as separate rows in a Popover dropdown so the donor explicitly
picks privacy (sp1) vs. compatibility (bc1) — matching how Ditto's send
dialog handles the same ambiguity. Refocusing or clicking the input
while it still contains a URI reopens the dropdown so the choice can be
changed without retyping.

Picking a row swaps the input out for a chip showing the chosen kind,
a truncated address, and an X to return to the input view. Bare bc1
or sp1 input still resolves directly, and single-option scans (URI with
only one valid candidate, bare address, bare sp1) bypass the dropdown
and go straight to the chip.

QR scanning moves into the picker, so the dialog no longer needs its
own scanner dialog or BIP-21 routing logic. The picker only supports
bc1 and sp1 destinations — pasted npub/nprofile is silently ignored
(no account search), matching Agora's narrower scope vs. Ditto.

The campaign donate flow used to pass two props (bc1 + sp1) and the
dialog rendered a swap toggle under the input. With the dropdown now
handling that choice natively, the toggle is gone and the campaign
page just builds a combined bitcoin:bc1?sp=sp1 URI as the prefill.
This commit is contained in:
Alex Gleason
2026-05-28 14:00:53 -05:00
parent 50b408cf9e
commit 92608f1471
20 changed files with 718 additions and 371 deletions
+457 -73
View File
@@ -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<string>(initialInput ?? '');
const [open, setOpen] = useState(false);
const [scannerOpen, setScannerOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(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<ResolvedRecipient | null>(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 (
<SelectedRecipientChip value={value} onClear={() => 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 (
<div className="space-y-2">
<div className="relative flex items-center">
<Input
id="hd-recipient-input"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
className={cn(
'font-mono text-base md:text-sm',
onScanClick && 'pr-11',
)}
/>
{onScanClick && (
<button
type="button"
onClick={onScanClick}
aria-label={scanLabel ?? 'Scan QR code'}
className="absolute right-1 top-1/2 -translate-y-1/2 size-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 flex items-center justify-center motion-safe:transition-colors"
>
<QrCode className="size-4" />
</button>
)}
</div>
<Popover open={popoverOpen} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div className="relative flex items-center">
<Input
ref={inputRef}
id="hd-recipient-input"
value={query}
onChange={(e) => 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')}
/>
<button
type="button"
onClick={() => setScannerOpen(true)}
aria-label={t('walletSend.recipient.scan')}
className="absolute right-1 top-1/2 -translate-y-1/2 size-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 flex items-center justify-center motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<QrCode className="size-4" />
</button>
</div>
</PopoverAnchor>
{resolvedPubkey && <ResolvedRecipientPreview pubkey={resolvedPubkey} />}
<PopoverContent
align="start"
sideOffset={6}
// Keep typing focus in the input on open/close — Radix's default
// is to focus the popover content, which would steal focus from
// the input and dismiss the mobile keyboard mid-type.
onOpenAutoFocus={(e) => 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"
>
<div role="listbox" className="max-h-[280px] overflow-y-auto py-1">
{/* SP comes before BTC so the privacy-preserving option is
the user's first scan target when both are present. */}
{hasSp && (
<SpAddressRow address={spCandidate} onClick={selectSp} />
)}
{hasBtc && (
<BtcAddressRow address={btcCandidate} onClick={selectBtc} />
)}
</div>
</PopoverContent>
</Popover>
<QrScannerDialog
isOpen={scannerOpen}
onClose={() => setScannerOpen(false)}
onScan={handleScan}
title={t('walletSend.recipient.scan')}
/>
</div>
);
}
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 (
<button
type="button"
role="option"
aria-selected={false}
onClick={() => onClick(address)}
// Prevent the input from blurring on mousedown — otherwise the popover
// closes before `onClick` fires and the row never resolves.
onMouseDown={(e) => e.preventDefault()}
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
>
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
<Bitcoin className="size-4 text-orange-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate">
{t('walletSend.recipient.sendToOnchain')}
</div>
<div className="text-xs text-muted-foreground truncate font-mono">
{truncateAddress(address)}
</div>
</div>
</button>
);
}
/**
* 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 (
<button
type="button"
role="option"
aria-selected={false}
onClick={() => onClick(address)}
onMouseDown={(e) => e.preventDefault()}
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
>
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
<EyeOff className="size-4 text-violet-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate">
{t('walletSend.recipient.sendToSilentPayment')}
</div>
<div className="text-xs text-muted-foreground truncate font-mono">
{truncateAddress(address)}
</div>
</div>
</button>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
<Avatar className="size-8 shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">{displayName}</span>
<span className="text-xs text-muted-foreground truncate block">
{metadata?.nip05?.startsWith('_@') ? metadata.nip05.slice(2) : metadata?.nip05 || fallbackLabel}
</span>
<div className="flex items-center gap-3 rounded-2xl border border-border bg-muted/40 pl-2 pr-2 py-1.5 w-full min-w-0 max-w-full">
{kind === 'sp' ? (
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
<EyeOff className="size-4 text-violet-500" />
</div>
) : (
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
<Bitcoin className="size-4 text-orange-500" />
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="text-[11px] text-muted-foreground leading-tight">
{t('walletSend.recipient.toLabel')}
</div>
<div className="text-sm font-medium truncate">{displayName}</div>
<div className="text-xs text-muted-foreground truncate font-mono">
{subtitle}
</div>
</div>
<button
type="button"
onClick={onClear}
aria-label={t('walletSend.recipient.clear')}
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors shrink-0"
>
<X className="size-4" />
</button>
</div>
);
}
+48 -208
View File
@@ -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<ResolvedRecipient | null>(null);
const [usdAmount, setUsdAmount] = useState<number | string>(5);
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
const [error, setError] = useState('');
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
const [success, setSuccess] = useState<SendResult | null>(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 (
<>
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
<DialogContent className="max-w-[425px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[95vh] [&>button]:hidden">
<DialogTitle className="sr-only">{t('walletSend.title')}</DialogTitle>
@@ -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. */}
<BitcoinRecipientInput
value={recipientInput}
onChange={setRecipientInput}
key={openCount}
value={recipient}
onChange={(next) => {
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) && (
<div className="grid gap-2">
{isRawAddress && (
<BitcoinPublicDisclaimer tone="soft" />
)}
{recipientSwap && (
<button
type="button"
onClick={() => setRecipientInput(recipientSwap.swapTo)}
className="self-start text-xs text-primary hover:underline motion-safe:transition-colors"
>
{t(recipientSwap.labelKey)}
</button>
)}
</div>
{/* Privacy disclaimer for raw on-chain addresses. SP
recipients produce a fresh unlinkable output per payment
and don't need the warning. */}
{isRawAddress && (
<BitcoinPublicDisclaimer tone="soft" />
)}
{/* Error */}
@@ -693,13 +540,6 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
)}
</DialogContent>
</Dialog>
<QrScannerDialog
isOpen={scannerOpen}
onClose={() => setScannerOpen(false)}
onScan={handleScan}
title={t('walletSend.recipient.scan')}
/>
</>
);
}
+3 -1
View File
@@ -8,6 +8,8 @@ const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
@@ -32,4 +34,4 @@ const PopoverContent = React.forwardRef<
})
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent }
+12 -5
View File
@@ -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}} ساتوشي",
+12 -5
View File
@@ -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"
},
+12 -5
View File
@@ -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ó"
},
+12 -5
View File
@@ -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": {
+12 -5
View File
@@ -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"
},
+12 -5
View File
@@ -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",
+12 -5
View File
@@ -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"
},
+12 -5
View File
@@ -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": "ប្រតិបត្តិការបរាជ័យ"
},
+12 -5
View File
@@ -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": "معامله ناکامه شوه"
},
+12 -5
View File
@@ -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",
+12 -5
View File
@@ -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": "Транзакция не удалась"
},
+12 -5
View File
@@ -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": {
+12 -5
View File
@@ -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"
},
+19 -12
View File
@@ -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": {
+12 -5
View File
@@ -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": "交易失敗"
},
+12 -5
View File
@@ -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": "交易失败"
},
+11 -2
View File
@@ -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
}
/>
)}
</Card>