Wallet UX polish: elegant recovery-phrase reveal, separate amounts, loading states

Continued the wallet audit with a focus on intuitive, minimal, elegant UX.

• Recovery phrase — replaced the raw browser alert() (which dumped the seed
  as unstyled, uncopyable plaintext) with a proper RecoveryPhraseDialog:
  hidden/blurred by default with a deliberate "tap to reveal" gate (no
  shoulder-surfing on open), a numbered 12-word chip grid for accurate
  transcription, a copy button (enabled only after reveal), and an honest
  "paper is safest, clipboards leak" note. Re-hides on every close. This was
  the wallet's most sensitive surface and its worst blemish.

• Fixed shared-state bug: "Create Cashu Token" and "Pay Lightning Address"
  both wrote to the same `sendAmount`, so switching between them carried a
  stale amount across two unrelated intents. The address form now owns its
  own amount state.

• Added loading feedback + double-tap protection to every money action that
  lacked it — Generate Invoice ("Generating…"), Create Token ("Creating…"),
  Receive Token ("Receiving…"), Pay Lightning Address ("Sending…"). Each hook
  now exposes an in-flight flag and guards re-entry.

WalletPage and the browser "app-only" notice were already clean/elegant —
left as-is.

Bumps to 1.2.22 (Android versionCode 10222 auto-derived).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-07 20:20:51 +07:00
parent 39ac517610
commit 7f49df1503
5 changed files with 215 additions and 32 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.2.21", "version": "1.2.22",
"type": "module", "type": "module",
"homepage": "https://skate-spots.com", "homepage": "https://skate-spots.com",
"scripts": { "scripts": {

View file

@ -39,6 +39,7 @@ import {
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { QRScannerModal } from '@/components/wallet/QRScannerModal'; import { QRScannerModal } from '@/components/wallet/QRScannerModal';
import { MintPrivacyManager } from '@/components/wallet/MintPrivacyManager'; import { MintPrivacyManager } from '@/components/wallet/MintPrivacyManager';
import { RecoveryPhraseDialog } from '@/components/wallet/RecoveryPhraseDialog';
import { isNativePlatform } from '@/lib/platform'; import { isNativePlatform } from '@/lib/platform';
import { Smartphone } from 'lucide-react'; import { Smartphone } from 'lucide-react';
@ -168,6 +169,7 @@ function CashuWalletCardNative() {
setShowInvoiceModal, setShowInvoiceModal,
currentInvoice, currentInvoice,
handleLightningReceive, handleLightningReceive,
isGeneratingInvoice,
lightningInvoice, lightningInvoice,
setLightningInvoice, setLightningInvoice,
isProcessingPayment, isProcessingPayment,
@ -178,9 +180,11 @@ function CashuWalletCardNative() {
tokenInput, tokenInput,
setTokenInput, setTokenInput,
handleTokenReceive, handleTokenReceive,
isReceivingToken,
sendAmount, sendAmount,
setSendAmount, setSendAmount,
handleTokenSend, handleTokenSend,
isCreatingToken,
sentToken, sentToken,
setSentToken, setSentToken,
sentTokenQRCode, sentTokenQRCode,
@ -194,9 +198,15 @@ function CashuWalletCardNative() {
// QR Scanner state // QR Scanner state
const [showQRScanner, setShowQRScanner] = useState(false); const [showQRScanner, setShowQRScanner] = useState(false);
// Lightning address state // Recovery-phrase reveal dialog
const [showRecoveryPhrase, setShowRecoveryPhrase] = useState(false);
// Lightning address state. Its amount is kept SEPARATE from the token-send
// amount (`sendAmount`) so switching between "Create Token" and "Pay Address"
// never carries a stale value between two unrelated intents.
const [lightningAddress, setLightningAddress] = useState(''); const [lightningAddress, setLightningAddress] = useState('');
const [lightningAddressAmount, setLightningAddressAmount] = useState('');
// Set first mint as active if none selected // Set first mint as active if none selected
useEffect(() => { useEffect(() => {
@ -242,34 +252,39 @@ function CashuWalletCardNative() {
}, [coco, setTokenInput, setLightningInvoice, toast]); }, [coco, setTokenInput, setLightningInvoice, toast]);
// Handle paying a Lightning address // Handle paying a Lightning address
const [isPayingAddress, setIsPayingAddress] = useState(false);
const handlePayLightningAddress = useCallback(async () => { const handlePayLightningAddress = useCallback(async () => {
if (!lightningAddress || !sendAmount || !coco || !activeMintUrl) return; if (!lightningAddress || !lightningAddressAmount || !coco || !activeMintUrl) return;
try { try {
const amount = parseInt(sendAmount); const amount = parseInt(lightningAddressAmount);
if (isNaN(amount) || amount <= 0) { if (isNaN(amount) || amount <= 0) {
toast({ variant: 'destructive', title: 'Invalid amount', description: 'Please enter a valid amount' }); toast({ variant: 'destructive', title: 'Invalid amount', description: 'Please enter a valid amount' });
return; return;
} }
setIsPayingAddress(true);
// First resolve the Lightning address to get an invoice // First resolve the Lightning address to get an invoice
const invoice = await getLightningInvoice({ lightningAddress, amountSats: amount }); const invoice = await getLightningInvoice({ lightningAddress, amountSats: amount });
// Then pay the invoice using Cashu // Then pay the invoice using Cashu
const meltQuote = await coco.quotes.createMeltQuote(activeMintUrl, invoice); const meltQuote = await coco.quotes.createMeltQuote(activeMintUrl, invoice);
await coco.quotes.payMeltQuote(activeMintUrl, meltQuote.quote); await coco.quotes.payMeltQuote(activeMintUrl, meltQuote.quote);
toast({ title: 'Payment sent', description: `Sent ${amount} sats to ${lightningAddress}` }); toast({ title: 'Payment sent', description: `Sent ${amount} sats to ${lightningAddress}` });
setLightningAddress(''); setLightningAddress('');
setSendAmount(''); setLightningAddressAmount('');
} catch (err) { } catch (err) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Payment failed', title: 'Payment failed',
description: err instanceof Error ? err.message : 'Unknown error' description: err instanceof Error ? err.message : 'Unknown error'
}); });
} finally {
setIsPayingAddress(false);
} }
}, [lightningAddress, sendAmount, coco, activeMintUrl, getLightningInvoice, toast, setSendAmount]); }, [lightningAddress, lightningAddressAmount, coco, activeMintUrl, getLightningInvoice, toast]);
// Copy to clipboard helper // Copy to clipboard helper
const copyToClipboard = async (text: string, label: string) => { const copyToClipboard = async (text: string, label: string) => {
@ -406,10 +421,10 @@ function CashuWalletCardNative() {
/> />
<Button <Button
onClick={handleLightningReceive} onClick={handleLightningReceive}
disabled={!activeMintUrl || !quoteAmount} disabled={!activeMintUrl || !quoteAmount || isGeneratingInvoice}
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold" className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
> >
Generate Invoice {isGeneratingInvoice ? 'Generating…' : 'Generate Invoice'}
</Button> </Button>
</div> </div>
</div> </div>
@ -446,10 +461,10 @@ function CashuWalletCardNative() {
/> />
<Button <Button
onClick={handleTokenReceive} onClick={handleTokenReceive}
disabled={!tokenInput} disabled={!tokenInput || isReceivingToken}
className="w-full h-11 bg-green-600 hover:bg-green-700 font-bold" className="w-full h-11 bg-green-600 hover:bg-green-700 font-bold"
> >
Receive Token {isReceivingToken ? 'Receiving…' : 'Receive Token'}
</Button> </Button>
</div> </div>
</div> </div>
@ -471,10 +486,10 @@ function CashuWalletCardNative() {
/> />
<Button <Button
onClick={handleTokenSend} onClick={handleTokenSend}
disabled={!activeMintUrl || !sendAmount} disabled={!activeMintUrl || !sendAmount || isCreatingToken}
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold" className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
> >
Create Token {isCreatingToken ? 'Creating…' : 'Create Token'}
</Button> </Button>
</div> </div>
</div> </div>
@ -543,16 +558,16 @@ function CashuWalletCardNative() {
type="number" type="number"
inputMode="numeric" inputMode="numeric"
placeholder="Amount in sats" placeholder="Amount in sats"
value={sendAmount} value={lightningAddressAmount}
onChange={(e) => setSendAmount(e.target.value)} onChange={(e) => setLightningAddressAmount(e.target.value)}
className="h-11" className="h-11"
/> />
<Button <Button
onClick={handlePayLightningAddress} onClick={handlePayLightningAddress}
disabled={!lightningAddress || !sendAmount || isResolvingAddress || isProcessingPayment} disabled={!lightningAddress || !lightningAddressAmount || isResolvingAddress || isPayingAddress}
className="w-full h-11 bg-purple-600 hover:bg-purple-700 font-bold" className="w-full h-11 bg-purple-600 hover:bg-purple-700 font-bold"
> >
{isResolvingAddress ? 'Resolving...' : 'Send to Address'} {isResolvingAddress || isPayingAddress ? 'Sending…' : 'Send to Address'}
</Button> </Button>
</div> </div>
</div> </div>
@ -668,9 +683,7 @@ function CashuWalletCardNative() {
Your wallet is secured with a 12-word recovery phrase. Save it safely — you'll need it to restore your balance if you clear browser data. Your wallet is secured with a 12-word recovery phrase. Save it safely — you'll need it to restore your balance if you clear browser data.
</p> </p>
<Button <Button
onClick={() => { onClick={() => setShowRecoveryPhrase(true)}
alert(`Your backup phrase:\n\n${mnemonic}\n\nWrite these words down and store them safely!`);
}}
variant="outline" variant="outline"
className="w-full h-11 border-amber-500/50 text-amber-600 hover:bg-amber-500/10 font-bold" className="w-full h-11 border-amber-500/50 text-amber-600 hover:bg-amber-500/10 font-bold"
> >
@ -770,6 +783,16 @@ function CashuWalletCardNative() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Recovery Phrase Dialog — replaces the old raw alert() with an elegant,
hidden-by-default reveal */}
{mnemonic && (
<RecoveryPhraseDialog
open={showRecoveryPhrase}
onOpenChange={setShowRecoveryPhrase}
mnemonic={mnemonic}
/>
)}
{/* QR Scanner Modal */} {/* QR Scanner Modal */}
<QRScannerModal <QRScannerModal
isOpen={showQRScanner} isOpen={showQRScanner}

View file

@ -0,0 +1,141 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { Shield, Eye, EyeOff, Copy, Check } from 'lucide-react';
interface RecoveryPhraseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Space-separated BIP-39 mnemonic. */
mnemonic: string;
}
/**
* An elegant, security-conscious recovery-phrase reveal — the wallet's single
* most sensitive surface, so it deserves better than a raw `alert()`.
*
* Principles:
* • Hidden by default (blurred word grid) so the seed can't be shoulder-surfed
* just by opening the dialog; the user must deliberately tap "Reveal".
* • Numbered word chips make it easy to transcribe accurately onto paper.
* • Copy is available but explicitly discouraged in the warning (clipboards
* leak); paper is the safe path.
* • The seed is cleared from the reveal state whenever the dialog closes.
*/
export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryPhraseDialogProps) {
const [revealed, setRevealed] = useState(false);
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const words = mnemonic.trim().split(/\s+/);
const handleOpenChange = (next: boolean) => {
// Re-hide on every close so re-opening always starts concealed.
if (!next) {
setRevealed(false);
setCopied(false);
}
onOpenChange(next);
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(mnemonic);
setCopied(true);
toast({ title: 'Copied', description: 'Recovery phrase copied — paste it somewhere safe, then clear your clipboard.' });
setTimeout(() => setCopied(false), 2500);
} catch {
toast({ variant: 'destructive', title: 'Copy failed', description: 'Please write the words down instead.' });
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-5 sm:p-6">
<DialogHeader>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500/15">
<Shield className="h-6 w-6 text-amber-500" />
</div>
<DialogTitle className="text-center text-lg font-black">Recovery Phrase</DialogTitle>
<DialogDescription className="text-center text-xs leading-relaxed">
These 12 words are the only way to restore your ecash balance. Write them down in order
and store them offline — never share them with anyone.
</DialogDescription>
</DialogHeader>
{/* Word grid */}
<div className="relative">
<div
className={cn(
'grid grid-cols-2 gap-2 rounded-2xl border border-amber-500/20 bg-amber-500/[0.04] p-3 transition-all sm:grid-cols-3',
!revealed && 'select-none blur-sm',
)}
aria-hidden={!revealed}
>
{words.map((word, i) => (
<div
key={`${i}-${word}`}
className="flex items-center gap-2 rounded-lg bg-background/60 px-2.5 py-2"
>
<span className="w-4 text-right text-[10px] font-bold tabular-nums text-muted-foreground">
{i + 1}
</span>
<span className="font-mono text-sm font-semibold">{word}</span>
</div>
))}
</div>
{/* Reveal overlay */}
{!revealed && (
<button
type="button"
onClick={() => setRevealed(true)}
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 rounded-2xl bg-background/40 backdrop-blur-[2px] transition-colors hover:bg-background/30 active:scale-[0.99]"
>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/20">
<Eye className="h-5 w-5 text-amber-500" />
</span>
<span className="text-xs font-bold uppercase tracking-wide text-amber-400">
Tap to reveal
</span>
<span className="text-[10px] text-muted-foreground">Make sure nobody's watching</span>
</button>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1 gap-2"
onClick={() => setRevealed((v) => !v)}
>
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{revealed ? 'Hide' : 'Reveal'}
</Button>
<Button
variant="outline"
className="flex-1 gap-2"
onClick={handleCopy}
disabled={!revealed}
>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
Writing it on paper is safest — copying to your clipboard can leak to other apps.
</p>
</DialogContent>
</Dialog>
);
}

View file

@ -27,6 +27,10 @@ export function useLightningOperations({
expiry: number; expiry: number;
} | null>(null); } | null>(null);
// Receive-generation in-flight flag (so the button can show progress and
// can't be double-tapped into two invoices).
const [isGeneratingInvoice, setIsGeneratingInvoice] = useState(false);
// Send state // Send state
const [lightningInvoice, setLightningInvoice] = useState(''); const [lightningInvoice, setLightningInvoice] = useState('');
const [isProcessingPayment, setIsProcessingPayment] = useState(false); const [isProcessingPayment, setIsProcessingPayment] = useState(false);
@ -35,7 +39,7 @@ export function useLightningOperations({
// Handle receive (create mint quote/invoice) // Handle receive (create mint quote/invoice)
const handleLightningReceive = useCallback(async () => { const handleLightningReceive = useCallback(async () => {
if (!coco || !quoteAmount || !activeMintUrl) return; if (isGeneratingInvoice || !coco || !quoteAmount || !activeMintUrl) return;
try { try {
const amount = parseInt(quoteAmount); const amount = parseInt(quoteAmount);
@ -47,6 +51,8 @@ export function useLightningOperations({
}); });
return; return;
} }
setIsGeneratingInvoice(true);
const quote = await coco.quotes.createMintQuote(activeMintUrl, amount); const quote = await coco.quotes.createMintQuote(activeMintUrl, amount);
const qrCodeUrl = await generateQRCode(quote.request); const qrCodeUrl = await generateQRCode(quote.request);
@ -103,8 +109,10 @@ export function useLightningOperations({
? `${activeMintUrl?.replace(/^https?:\/\//, '')} can't be reached from your browser (no CORS). Switch to a web-friendly mint in wallet settings — or use the native app, where any mint works.` ? `${activeMintUrl?.replace(/^https?:\/\//, '')} can't be reached from your browser (no CORS). Switch to a web-friendly mint in wallet settings — or use the native app, where any mint works.`
: raw, : raw,
}); });
} finally {
setIsGeneratingInvoice(false);
} }
}, [coco, quoteAmount, activeMintUrl, generateQRCode, toast]); }, [coco, quoteAmount, activeMintUrl, generateQRCode, isGeneratingInvoice, toast]);
// Handle send (pay Lightning invoice) // Handle send (pay Lightning invoice)
const handleLightningSend = useCallback(async () => { const handleLightningSend = useCallback(async () => {
@ -159,6 +167,7 @@ export function useLightningOperations({
currentInvoice, currentInvoice,
setCurrentInvoice, setCurrentInvoice,
handleLightningReceive, handleLightningReceive,
isGeneratingInvoice,
// Send // Send
lightningInvoice, lightningInvoice,

View file

@ -21,10 +21,12 @@ export function useTokenOperations({
// Receive state // Receive state
const [tokenInput, setTokenInput] = useState(''); const [tokenInput, setTokenInput] = useState('');
const [isReceivingToken, setIsReceivingToken] = useState(false);
// Send state // Send state
const [sendAmount, setSendAmount] = useState(''); const [sendAmount, setSendAmount] = useState('');
const [isCreatingToken, setIsCreatingToken] = useState(false);
// Transaction output state // Transaction output state
const [sentToken, setSentToken] = useState<string | null>(null); const [sentToken, setSentToken] = useState<string | null>(null);
const [sentTokenQRCode, setSentTokenQRCode] = useState<string>(''); const [sentTokenQRCode, setSentTokenQRCode] = useState<string>('');
@ -83,8 +85,9 @@ export function useTokenOperations({
// Handle receive (token) // Handle receive (token)
const handleTokenReceive = useCallback(async () => { const handleTokenReceive = useCallback(async () => {
if (!coco || !tokenInput) return; if (isReceivingToken || !coco || !tokenInput) return;
setIsReceivingToken(true);
try { try {
const tokenToProcess = tokenInput.trim(); const tokenToProcess = tokenInput.trim();
@ -113,12 +116,14 @@ export function useTokenOperations({
title: 'Receive failed', title: 'Receive failed',
description: err instanceof Error ? err.message : 'Unknown error' description: err instanceof Error ? err.message : 'Unknown error'
}); });
} finally {
setIsReceivingToken(false);
} }
}, [coco, tokenInput, handleUnknownMintError, toast]); }, [coco, tokenInput, isReceivingToken, handleUnknownMintError, toast]);
// Handle send (token) // Handle send (token)
const handleTokenSend = useCallback(async () => { const handleTokenSend = useCallback(async () => {
if (!coco || !sendAmount || !activeMintUrl) return; if (isCreatingToken || !coco || !sendAmount || !activeMintUrl) return;
try { try {
const amount = parseInt(sendAmount); const amount = parseInt(sendAmount);
@ -130,7 +135,8 @@ export function useTokenOperations({
}); });
return; return;
} }
setIsCreatingToken(true);
const token = await coco.wallet.send(activeMintUrl, amount); const token = await coco.wallet.send(activeMintUrl, amount);
const encodedToken = getEncodedToken(token); const encodedToken = getEncodedToken(token);
setSentToken(encodedToken); setSentToken(encodedToken);
@ -147,18 +153,22 @@ export function useTokenOperations({
title: 'Send failed', title: 'Send failed',
description: err instanceof Error ? err.message : 'Unknown error' description: err instanceof Error ? err.message : 'Unknown error'
}); });
} finally {
setIsCreatingToken(false);
} }
}, [coco, sendAmount, activeMintUrl, getEncodedToken, generateSentTokenQRCode, toast]); }, [coco, sendAmount, activeMintUrl, isCreatingToken, getEncodedToken, generateSentTokenQRCode, toast]);
return { return {
// Receive state // Receive state
tokenInput, tokenInput,
setTokenInput, setTokenInput,
handleTokenReceive, handleTokenReceive,
isReceivingToken,
// Send state // Send state
sendAmount, sendAmount,
setSendAmount, setSendAmount,
handleTokenSend, handleTokenSend,
isCreatingToken,
// Transaction output state // Transaction output state
sentToken, sentToken,
setSentToken, setSentToken,