diff --git a/package-lock.json b/package-lock.json index cf7978e6..0dbc0241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.12.1", + "version": "2.12.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.12.1", + "version": "2.12.2", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", "@capacitor/app": "^8.0.0", diff --git a/src/components/OnchainZapContent.tsx b/src/components/OnchainZapContent.tsx index 6596e392..bf86f2c7 100644 --- a/src/components/OnchainZapContent.tsx +++ b/src/components/OnchainZapContent.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo, useCallback, useEffect } from 'react'; -import { AlertTriangle, Zap, Gauge, Loader2, Bitcoin, Copy, Check } from 'lucide-react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { AlertTriangle, Gauge, Loader2, Bitcoin, Copy, Check, ChevronDown } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; @@ -40,6 +40,40 @@ const FEE_SPEED_LABELS: Record = { economy: '~1 day', }; +const FEE_SPEED_ORDER: OnchainFeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy']; + +/** + * Given the raw mempool fee rates (sat/vB), return a deduplicated list of + * speed tiers. When multiple tiers share the same rate (common when the + * mempool is empty and everything collapses to 1 sat/vB), we keep only the + * fastest-labeled tier for that rate. This prevents rows like "~10 min 2 + * sat/vB / ~30 min 2 sat/vB / ~1 hour 2 sat/vB" in the UI. + */ +function getRateForSpeed(rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number }, speed: OnchainFeeSpeed): number { + switch (speed) { + case 'fastest': return rates.fastestFee; + case 'halfHour': return rates.halfHourFee; + case 'hour': return rates.hourFee; + case 'economy': return rates.economyFee; + } +} + +function getUniqueFeeSpeeds( + rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number } | undefined, +): OnchainFeeSpeed[] { + if (!rates) return FEE_SPEED_ORDER; + const seen = new Set(); + const result: OnchainFeeSpeed[] = []; + for (const speed of FEE_SPEED_ORDER) { + const rate = getRateForSpeed(rates, speed); + if (!seen.has(rate)) { + seen.add(rate); + result.push(speed); + } + } + return result; +} + interface OnchainZapContentProps { target: NostrEvent; onSuccess?: () => void; @@ -64,6 +98,13 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) const [feeSpeed, setFeeSpeed] = useState('halfHour'); const [error, setError] = useState(''); const [feePopoverOpen, setFeePopoverOpen] = useState(false); + const [editingAmount, setEditingAmount] = useState(false); + const [commentOpen, setCommentOpen] = useState(false); + const amountInputRef = useRef(null); + + // Tracks whether the user has manually picked a fee speed. Once true, we + // stop auto-adjusting the fee in response to amount changes. + const feeSpeedUserChanged = useRef(false); const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : ''; const recipientAddress = useMemo(() => nostrPubkeyToBitcoinAddress(target.pubkey), [target.pubkey]); @@ -95,12 +136,7 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) const currentFeeRate = useMemo(() => { if (!feeRates) return 0; - switch (feeSpeed) { - case 'fastest': return feeRates.fastestFee; - case 'halfHour': return feeRates.halfHourFee; - case 'hour': return feeRates.hourFee; - case 'economy': return feeRates.economyFee; - } + return getRateForSpeed(feeRates, feeSpeed); }, [feeRates, feeSpeed]); // Convert the USD amount to sats @@ -124,6 +160,40 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) const insufficient = totalBalance > 0 && totalSats > totalBalance; const showBalance = insufficient || (amountSats > 0 && totalBalance === 0); + // Auto-adjust fee speed when the amount changes, unless the user has + // already picked a speed manually. Aim for a fee below 40% of the amount + // by stepping down through the unique speed tiers. If every tier still + // blows past 40% (tiny amount), fall back to the cheapest tier so we at + // least minimize the hit. + useEffect(() => { + if (feeSpeedUserChanged.current) return; + if (!utxos?.length || !feeRates || amountSats <= 0) return; + + const uniqueSpeeds = getUniqueFeeSpeeds(feeRates); + const threshold = amountSats * 0.4; + + let target: OnchainFeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1]; + for (const speed of uniqueSpeeds) { + const rate = getRateForSpeed(feeRates, speed); + const fee2 = estimateFee(utxos.length, 2, rate); + const change = totalBalance - amountSats - fee2; + const outputs = change > 546 ? 2 : 1; + const fee = estimateFee(utxos.length, outputs, rate); + if (fee <= threshold) { + target = speed; + break; + } + } + + setFeeSpeed((prev) => (prev === target ? prev : target)); + }, [amountSats, feeRates, utxos, totalBalance]); + + const handleFeeSpeedChange = useCallback((speed: OnchainFeeSpeed) => { + feeSpeedUserChanged.current = true; + setFeeSpeed(speed); + setFeePopoverOpen(false); + }, []); + // For large amounts, require a two-tap confirmation on the primary button. // This catches fat-finger sends without nagging on normal amounts. const isLarge = isLargeAmount(totalSats, btcPrice); @@ -174,6 +244,28 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) // resulting txid, so we don't publish a kind 8333 — the user is warned // that the zap won't be attributed to them on Nostr. + const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount; + const hasValidAmount = Number.isFinite(currentUsd) && currentUsd > 0; + const totalUsdString = btcPrice ? satsToUSD(totalSats, btcPrice) : ''; + const uniqueFeeSpeeds = useMemo(() => getUniqueFeeSpeeds(feeRates), [feeRates]); + + // Clicking the big amount flips it into edit mode. Auto-focus and + // select-all so typing overwrites the current value. + useEffect(() => { + if (editingAmount) { + amountInputRef.current?.focus(); + amountInputRef.current?.select(); + } + }, [editingAmount]); + + const commitAmountEdit = useCallback(() => { + setEditingAmount(false); + // Normalize empty string to 0 so the display doesn't show "$" alone. + if (typeof usdAmount === 'string' && usdAmount.trim() === '') { + setUsdAmount(0); + } + }, [usdAmount]); + if (user && capability === 'unsupported') { return ( - {/* Amount presets (USD) */} +
+ {/* Amount — big number on top, editable by clicking. */} +
+ {editingAmount ? ( +
+ $ + { setUsdAmount(e.target.value); setError(''); }} + onBlur={commitAmountEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitAmountEdit(); + } + }} + aria-label="Amount in USD" + className="bg-transparent border-0 outline-none text-4xl font-semibold text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + style={{ width: `${Math.max(2, String(usdAmount).length + 1)}ch` }} + /> +
+ ) : ( + + )} + {amountSats > 0 && ( + + {formatSats(amountSats)} sats + + )} +
+ + {/* Preset buttons sit under the big number. */} { if (v) { setUsdAmount(Number(v)); setError(''); } }} + onValueChange={(v) => { if (v) { setUsdAmount(Number(v)); setError(''); setEditingAmount(false); } }} className="grid grid-cols-5 gap-1 w-full" > {USD_PRESETS.map((v) => ( @@ -211,47 +346,30 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) ))} -
-
- OR -
+ {/* Comment — hidden behind a text-only accordion chevron. */} +
+ + {commentOpen && ( +