Simplify on-chain zap dialog
Redesign the on-chain flow around a sleek 'Send $X' experience: click the big number to edit, presets sit underneath, comment collapses behind a chevron, recipient address and OR divider are gone, the send button just says 'Send $5.20' with the fee included, and fee speeds are deduplicated so duplicate sat/vB tiers don't repeat. The fee speed now auto-adjusts when the amount changes to keep the fee below 40% of the send amount — once the user manually picks a speed, auto-adjustment is disabled for the session. Dialog framing switches from 'Send a Zap' to 'Send Bitcoin', the redundant description line is dropped, and the (?) popover routes to one of two new tab-specific FAQ entries (send-bitcoin-onchain or send-bitcoin-lightning).
This commit is contained in:
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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<OnchainFeeSpeed, string> = {
|
||||
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<number>();
|
||||
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<OnchainFeeSpeed>('halfHour');
|
||||
const [error, setError] = useState('');
|
||||
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
|
||||
const [editingAmount, setEditingAmount] = useState(false);
|
||||
const [commentOpen, setCommentOpen] = useState(false);
|
||||
const amountInputRef = useRef<HTMLInputElement>(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 (
|
||||
<UnsupportedSignerQR
|
||||
@@ -189,15 +281,58 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps)
|
||||
);
|
||||
}
|
||||
|
||||
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
|
||||
{/* Amount presets (USD) */}
|
||||
<div className="grid gap-4 px-4 py-4 w-full overflow-hidden">
|
||||
{/* Amount — big number on top, editable by clicking. */}
|
||||
<div className="flex flex-col items-center pt-2">
|
||||
{editingAmount ? (
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className="text-4xl font-semibold text-muted-foreground">$</span>
|
||||
<input
|
||||
ref={amountInputRef}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={usdAmount}
|
||||
onChange={(e) => { 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` }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingAmount(true)}
|
||||
aria-label="Edit amount"
|
||||
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
|
||||
>
|
||||
<span className="text-4xl font-semibold text-muted-foreground">$</span>
|
||||
<span className="text-4xl font-semibold tabular-nums">
|
||||
{hasValidAmount ? currentUsd : 0}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{amountSats > 0 && (
|
||||
<span className="mt-1 text-xs text-muted-foreground tabular-nums">
|
||||
{formatSats(amountSats)} sats
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preset buttons sit under the big number. */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
|
||||
onValueChange={(v) => { 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)
|
||||
))}
|
||||
</ToggleGroup>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-muted" />
|
||||
<span className="text-xs text-muted-foreground">OR</span>
|
||||
<div className="h-px flex-1 bg-muted" />
|
||||
{/* Comment — hidden behind a text-only accordion chevron. */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommentOpen((v) => !v)}
|
||||
aria-expanded={commentOpen}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3.5 transition-transform ${commentOpen ? 'rotate-0' : '-rotate-90'}`}
|
||||
/>
|
||||
<span>{comment ? 'Comment' : 'Add a comment'}</span>
|
||||
</button>
|
||||
{commentOpen && (
|
||||
<Textarea
|
||||
placeholder="Say something (optional)"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">$</span>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="Custom amount (USD)"
|
||||
value={usdAmount}
|
||||
onChange={(e) => { setUsdAmount(e.target.value); setError(''); }}
|
||||
className="pl-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<Textarea
|
||||
placeholder="Add a comment (optional)"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
|
||||
{/* Recipient Bitcoin address — always shown so users can verify the
|
||||
derived destination before signing. */}
|
||||
{recipientAddress && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Bitcoin className="size-3.5 text-orange-500 shrink-0" />
|
||||
<span className="shrink-0">To:</span>
|
||||
<span className="font-mono truncate" title={recipientAddress}>{truncatedRecipient}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fee line — click to open speed picker */}
|
||||
{amountSats > 0 && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
@@ -273,19 +391,14 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps)
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" sideOffset={6} className="w-56 p-1">
|
||||
<div className="flex flex-col">
|
||||
{(Object.keys(FEE_SPEED_LABELS) as OnchainFeeSpeed[]).map((speed) => {
|
||||
const rate = feeRates
|
||||
? speed === 'fastest' ? feeRates.fastestFee
|
||||
: speed === 'halfHour' ? feeRates.halfHourFee
|
||||
: speed === 'hour' ? feeRates.hourFee
|
||||
: feeRates.economyFee
|
||||
: 0;
|
||||
{uniqueFeeSpeeds.map((speed) => {
|
||||
const rate = feeRates ? getRateForSpeed(feeRates, speed) : 0;
|
||||
const selected = speed === feeSpeed;
|
||||
return (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
onClick={() => { setFeeSpeed(speed); setFeePopoverOpen(false); }}
|
||||
onClick={() => handleFeeSpeedChange(speed)}
|
||||
className={`flex items-center justify-between px-2 py-1.5 rounded-sm text-xs text-left hover:bg-muted transition-colors ${selected ? 'bg-muted font-medium' : ''}`}
|
||||
>
|
||||
<span>{FEE_SPEED_LABELS[speed]}</span>
|
||||
@@ -322,16 +435,9 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps)
|
||||
{progressLabel(progress)}
|
||||
</>
|
||||
) : isLarge && confirmArmed ? (
|
||||
<>
|
||||
<Zap className="size-4 mr-1.5" />
|
||||
Tap again to send {currentUsd > 0 ? `$${currentUsd}` : ''}
|
||||
</>
|
||||
<>Tap again to send {totalUsdString}</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="size-4 mr-1.5" />
|
||||
Zap {currentUsd > 0 ? `$${currentUsd}` : ''}
|
||||
{amountSats > 0 && ` · ${formatSats(amountSats)} sats`}
|
||||
</>
|
||||
<>Send {totalUsdString || (hasValidAmount ? `$${currentUsd}` : '')}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -441,7 +441,16 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
<DialogContent className="max-w-[425px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[95vh] [&>button]:hidden" data-testid="zap-modal">
|
||||
<div className="flex items-center justify-between px-4 h-12">
|
||||
<DialogTitle className="text-base font-semibold flex items-center gap-1.5">
|
||||
{invoice ? 'Lightning Payment' : 'Send a Zap'} <HelpTip faqId="what-are-zaps" />
|
||||
{invoice
|
||||
? 'Lightning Payment'
|
||||
: 'Send Bitcoin'}{' '}
|
||||
<HelpTip
|
||||
faqId={
|
||||
invoice || activeTab === 'lightning'
|
||||
? 'send-bitcoin-lightning'
|
||||
: 'send-bitcoin-onchain'
|
||||
}
|
||||
/>
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
@@ -450,13 +459,6 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="px-4 -mt-1 mb-1 text-sm text-muted-foreground">
|
||||
{invoice
|
||||
? 'Pay with Bitcoin Lightning Network'
|
||||
: activeTab === 'onchain'
|
||||
? 'Send Bitcoin to support the creator.'
|
||||
: 'Send a Lightning payment to support the creator.'}
|
||||
</p>
|
||||
<div className="overflow-y-auto">
|
||||
{hasLightning ? (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'onchain' | 'lightning')} className="w-full">
|
||||
|
||||
@@ -156,6 +156,24 @@ const FAQ_TEMPLATE: FAQCategory[] = [
|
||||
'Think of it like a super-powered Like button that actually sends real money. They use the Lightning Network, which makes them instant and nearly free. To learn more, check out [Understanding Zaps](https://nostr.how/en/zaps).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'send-bitcoin-onchain',
|
||||
question: 'How does sending Bitcoin work?',
|
||||
answer: [
|
||||
'You\'re sending Bitcoin directly on the Bitcoin blockchain. The funds come from your {appName} Bitcoin balance and land in the recipient\'s wallet after a miner confirms the transaction.',
|
||||
'You choose a network fee based on how soon you want the transaction confirmed \u2014 faster means a higher fee. {appName} picks a sensible default for you, but you can change it.',
|
||||
'Once sent, the transaction is public and irreversible. The post is tagged so the creator knows you sent the Bitcoin as a tip.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'send-bitcoin-lightning',
|
||||
question: 'How does sending Bitcoin over Lightning work?',
|
||||
answer: [
|
||||
'Lightning is a faster, cheaper layer built on top of Bitcoin. Payments settle in seconds and fees are usually fractions of a cent.',
|
||||
'You\'ll pay from your connected Lightning wallet. The creator receives the Bitcoin right away, and the payment is attached to their post as a zap so everyone can see the support.',
|
||||
'To learn more, check out [Understanding Zaps](https://nostr.how/en/zaps).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'connect-wallet',
|
||||
question: 'How do I connect a wallet?',
|
||||
|
||||
Reference in New Issue
Block a user