diff --git a/App.tsx b/App.tsx index 3b36c822..70aa8aae 100644 --- a/App.tsx +++ b/App.tsx @@ -7,9 +7,10 @@ import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import Toast, { BaseToast, ErrorToast, InfoToast } from 'react-native-toast-message'; -import { WalletProvider } from './src/contexts/WalletContext'; +import { WalletProvider, useWallet } from './src/contexts/WalletContext'; import { NostrProvider } from './src/contexts/NostrContext'; import AppNavigator from './src/navigation/AppNavigator'; +import PaymentProgressOverlay from './src/components/PaymentProgressOverlay'; // Render toasts with unlimited-line body so long error messages (e.g. Electrum // script-verify errors) aren't truncated. Height grows to fit content. @@ -43,6 +44,27 @@ const toastConfig = { ), }; +// Renders the global incoming-payment celebration on top of the nav +// stack. Lives inside the WalletProvider so it can subscribe to the +// context's incoming-payment event bus, and above any screen so the +// confetti pops no matter where the user is when a payment lands. +function GlobalIncomingPaymentOverlay() { + const { lastIncomingPayment, clearLastIncomingPayment } = useWallet(); + // Key on the event timestamp so a second payment arriving while the + // overlay is still visible remounts the component and re-arms the + // confetti animation. Without this, a second `success` in a row + // wouldn't retrigger the burst (state stays 'success', no transition). + return ( + + ); +} + export default function App() { return ( @@ -53,6 +75,7 @@ export default function App() { + diff --git a/src/components/PaymentProgressOverlay.tsx b/src/components/PaymentProgressOverlay.tsx new file mode 100644 index 00000000..f28da68a --- /dev/null +++ b/src/components/PaymentProgressOverlay.tsx @@ -0,0 +1,520 @@ +import React, { useEffect, useMemo } from 'react'; +import { + Modal, + View, + Text, + StyleSheet, + useWindowDimensions, + ActivityIndicator, + TouchableOpacity, +} from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + useAnimatedReaction, + withTiming, + withDelay, + withRepeat, + withSpring, + withSequence, + interpolate, + interpolateColor, + cancelAnimation, + Easing, +} from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; +import { Check, X } from 'lucide-react-native'; +import { colors } from '../styles/theme'; + +export type PaymentProgressState = 'sending' | 'success' | 'error' | 'hidden'; +export type PaymentDirection = 'send' | 'receive'; + +interface Props { + state: PaymentProgressState; + direction?: PaymentDirection; // default 'send' + amountSats?: number; + recipientName?: string; + errorMessage?: string; + onDismiss: () => void; +} + +const BUBBLE_COUNT = 140; +const CONFETTI_COUNT = 135; +// Screen should be packed with bubbles by ~5s. Quadratic stagger means +// early bubbles are sparse and density ramps up rapidly toward the 5s mark. +const FULL_DENSITY_MS = 5000; + +// On-brand confetti palette — Piggy pink plus blues and purples. +const CONFETTI_COLORS = [ + colors.brandPink, + '#FF6BB7', // light pink + '#7A5CFF', // violet + '#5B8DEF', // blue + '#22C1E4', // cyan + '#A77BFF', // lavender +]; + +interface BubbleSpec { + index: number; + startXRatio: number; + size: number; + duration: number; + driftPx: number; + delayMs: number; + opacityPeak: number; +} + +function makeSpecs(count: number, screenHeight: number): BubbleSpec[] { + const specs: BubbleSpec[] = []; + for (let i = 0; i < count; i++) { + const t = i / count; // 0..1 + // Quadratic stagger: sparse at first, denser toward FULL_DENSITY_MS. + const delayMs = t * t * FULL_DENSITY_MS; + specs.push({ + index: i, + startXRatio: Math.random(), + size: 22 + Math.random() * 46, + duration: 2400 + Math.random() * 1800, + driftPx: -40 + Math.random() * 80, + delayMs, + opacityPeak: 0.35 + Math.random() * 0.4, + }); + } + // Unused screenHeight param kept for future tuning (eg size scaling + // on short screens). Silence the lint by using it. + void screenHeight; + return specs; +} + +interface ConfettiSpec { + index: number; + width: number; + height: number; + color: string; + // Radial burst from the card centre: initial velocity (px/s). + vx: number; + vy: number; + // Gravity pulls pieces down after the initial burst (px/s²). + gravity: number; + // Total animation duration (ms). + duration: number; + // Small per-piece launch stagger so the burst feels alive, not mechanical. + delayMs: number; + spinTurns: number; + opacityPeak: number; +} + +function makeConfettiSpecs(count: number): ConfettiSpec[] { + const specs: ConfettiSpec[] = []; + for (let i = 0; i < count; i++) { + // Pick a random angle across the full circle, then bias slightly + // upward so the burst feels explosive rather than dribbling straight + // down into gravity. + const angle = Math.random() * Math.PI * 2; + const speed = 320 + Math.random() * 380; // px/s + const vx = Math.cos(angle) * speed; + // Bias upward: subtract a small extra upward component so on average + // pieces launch *outward-and-up* before gravity takes over. + const vy = Math.sin(angle) * speed - 80; + specs.push({ + index: i, + width: 7 + Math.random() * 6, + height: 10 + Math.random() * 8, + color: CONFETTI_COLORS[i % CONFETTI_COLORS.length], + vx, + vy, + gravity: 780 + Math.random() * 180, + duration: 1800 + Math.random() * 900, + delayMs: Math.random() * 220, + spinTurns: 1.5 + Math.random() * 3.5, + opacityPeak: 0.9 + Math.random() * 0.1, + }); + } + return specs; +} + +interface BubbleProps { + spec: BubbleSpec; + colorProgress: SharedValue; + screenWidth: number; + screenHeight: number; +} + +function Bubble({ spec, colorProgress, screenWidth, screenHeight }: BubbleProps) { + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = withDelay( + spec.delayMs, + withRepeat(withTiming(1, { duration: spec.duration, easing: Easing.linear }), -1, false), + ); + return () => cancelAnimation(progress); + }, [progress, spec.delayMs, spec.duration]); + + const animatedStyle = useAnimatedStyle(() => { + const y = interpolate(progress.value, [0, 1], [screenHeight + 60, -80]); + const xOffset = Math.sin(progress.value * Math.PI * 2) * spec.driftPx; + const opacity = interpolate( + progress.value, + [0, 0.12, 0.85, 1], + [0, spec.opacityPeak, spec.opacityPeak, 0], + ); + const bg = interpolateColor(colorProgress.value, [0, 1], [colors.brandPink, colors.green]); + return { + transform: [{ translateX: xOffset }, { translateY: y }], + opacity, + backgroundColor: bg, + }; + }); + + const baseLeft = spec.startXRatio * screenWidth - spec.size / 2; + + return ( + + ); +} + +interface ConfettiProps { + spec: ConfettiSpec; + armed: SharedValue; + originX: number; + originY: number; +} + +function Confetti({ spec, armed, originX, originY }: ConfettiProps) { + // `progress` goes 0 → 1 across `spec.duration`. We interpret it as + // elapsed-time in seconds via `progress * duration/1000` and plug that + // into a standard projectile equation with gravity. + const progress = useSharedValue(0); + + useAnimatedReaction( + () => armed.value, + (armedNow, armedBefore) => { + if (armedNow === 1 && armedBefore !== 1) { + progress.value = withDelay( + spec.delayMs, + withTiming(1, { duration: spec.duration, easing: Easing.linear }), + ); + } else if (armedNow === 0) { + cancelAnimation(progress); + progress.value = 0; + } + }, + ); + + const animatedStyle = useAnimatedStyle(() => { + // Seconds since this piece launched. + const t = (progress.value * spec.duration) / 1000; + // Classic projectile: s = v0·t + ½·g·t². Horizontal has no accel. + const tx = spec.vx * t; + const ty = spec.vy * t + 0.5 * spec.gravity * t * t; + const rotate = `${progress.value * spec.spinTurns * 360}deg`; + // Quick fade-in at the start (so it pops from behind the card), then + // a longer tail fade as pieces fall past the edges. + const opacity = interpolate( + progress.value, + [0, 0.06, 0.75, 1], + [0, spec.opacityPeak, spec.opacityPeak, 0], + ); + return { + transform: [{ translateX: tx }, { translateY: ty }, { rotate }], + opacity, + }; + }); + + return ( + + ); +} + +export default function PaymentProgressOverlay({ + state, + direction = 'send', + amountSats, + recipientName, + errorMessage, + onDismiss, +}: Props) { + const { width, height } = useWindowDimensions(); + + // Keep the overlay mounted across `hidden` so bubbles don't flash + // when state flips back to sending mid-flow. We drive the Modal's + // `visible` from state. + const visible = state !== 'hidden'; + + const bubbleSpecs = useMemo(() => makeSpecs(BUBBLE_COUNT, height), [height]); + const confettiSpecs = useMemo(() => makeConfettiSpecs(CONFETTI_COUNT), []); + + // 0 = pink (sending / error), 1 = green (success). The error case + // keeps pink so the green-flood doesn't imply success on a failure. + const colorProgress = useSharedValue(0); + // 0 while holding fire, 1 once success fires — gates the confetti launch. + const confettiArmed = useSharedValue(0); + const cardScale = useSharedValue(0.9); + const cardOpacity = useSharedValue(0); + const iconScale = useSharedValue(0); + + // Entry animation when overlay first appears. + useEffect(() => { + if (visible) { + cardScale.value = withSpring(1, { damping: 14, stiffness: 180 }); + cardOpacity.value = withTiming(1, { duration: 220 }); + } else { + cardScale.value = 0.9; + cardOpacity.value = 0; + colorProgress.value = 0; + iconScale.value = 0; + confettiArmed.value = 0; + } + }, [visible, cardScale, cardOpacity, colorProgress, iconScale, confettiArmed]); + + // Drive colour + icon animations on state change. Dismissal is + // user-driven — neither send nor receive auto-closes, so the user + // always confirms they saw the outcome before we tear down the sheet. + useEffect(() => { + if (state === 'success') { + if (direction === 'send') { + // Bubbles morph from pink to green on a successful send. + colorProgress.value = withTiming(1, { duration: 650 }); + } else { + // Receive: fire the on-brand confetti burst. We delay the + // launch by 280ms so the card visibly springs in *first* and + // the burst reads as coming from behind it. + confettiArmed.value = withDelay(280, withTiming(1, { duration: 0 })); + } + iconScale.value = withSequence( + withTiming(0, { duration: 0 }), + withSpring(1, { damping: 10, stiffness: 220 }), + ); + } else if (state === 'error') { + iconScale.value = withSequence( + withTiming(0, { duration: 0 }), + withSpring(1, { damping: 10, stiffness: 220 }), + ); + } else if (state === 'sending') { + colorProgress.value = 0; + iconScale.value = 0; + confettiArmed.value = 0; + } + }, [state, direction, colorProgress, iconScale, confettiArmed]); + + const cardAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: cardScale.value }], + opacity: cardOpacity.value, + })); + + const iconAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: iconScale.value }], + opacity: iconScale.value, + })); + + const formattedAmount = + typeof amountSats === 'number' && amountSats > 0 + ? `${amountSats.toLocaleString()} sats` + : undefined; + + const isReceive = direction === 'receive'; + let title = isReceive ? 'Waiting for payment…' : 'Sending payment…'; + let subtitle: string | undefined = recipientName + ? isReceive + ? `from ${recipientName}` + : `to ${recipientName}` + : formattedAmount; + if (state === 'success') { + title = isReceive ? 'Payment received!' : 'Payment sent!'; + subtitle = formattedAmount + ? recipientName + ? isReceive + ? `${formattedAmount} from ${recipientName}` + : `${formattedAmount} to ${recipientName}` + : formattedAmount + : recipientName + ? isReceive + ? `from ${recipientName}` + : `to ${recipientName}` + : undefined; + } else if (state === 'error') { + title = 'Payment failed'; + subtitle = errorMessage || 'Please try again.'; + } + + // Android expects a stable `onRequestClose` for hardware-back behaviour + // — passing `undefined` intermittently can warn and makes the button + // feel inconsistent. Always provide a handler; swallow the back press + // while the payment is still in flight so the user doesn't accidentally + // dismiss the "Sending…" state and lose sight of the outcome. + const handleRequestClose = () => { + if (state === 'sending') return; + onDismiss(); + }; + + return ( + + + {/* Particle layer renders BEHIND the card — later siblings stack + * above earlier ones in RN, so this block must come first. + * Send = pink bubbles rising; Receive = radial confetti burst + * from card centre, so pieces appear to launch out from behind + * the card and fly past its edges. */} + + {isReceive + ? confettiSpecs.map((spec) => ( + + )) + : bubbleSpecs.map((spec) => ( + + ))} + + + + {state === 'sending' && ( + + )} + {state === 'success' && ( + + + + )} + {state === 'error' && ( + + + + )} + + {title} + {subtitle ? {subtitle} : null} + + {/* The user must acknowledge send/receive/error outcomes before + * we tear down the sheet — auto-dismiss can hide the fact the + * money moved if they weren't looking. No button while sending. */} + {state !== 'sending' ? ( + + {state === 'error' ? 'Dismiss' : 'OK'} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: 'rgba(21, 23, 26, 0.45)', + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, + bubble: { + position: 'absolute', + }, + confetti: { + position: 'absolute', + borderRadius: 2, + }, + card: { + backgroundColor: colors.white, + borderRadius: 28, + paddingVertical: 32, + paddingHorizontal: 28, + minWidth: 260, + maxWidth: 340, + alignItems: 'center', + gap: 14, + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.2, + shadowRadius: 24, + elevation: 12, + }, + iconSlot: { + width: 72, + height: 72, + alignItems: 'center', + justifyContent: 'center', + }, + successCircle: { + borderRadius: 36, + backgroundColor: colors.green, + }, + errorCircle: { + borderRadius: 36, + backgroundColor: colors.red, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: colors.textHeader, + textAlign: 'center', + }, + subtitle: { + fontSize: 14, + color: colors.textSupplementary, + textAlign: 'center', + }, + okButton: { + marginTop: 12, + alignSelf: 'stretch', + backgroundColor: colors.brandPink, + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 14, + alignItems: 'center', + }, + okButtonText: { + color: colors.white, + fontSize: 16, + fontWeight: '700', + letterSpacing: 0.3, + }, +}); diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index 94832485..beea2d65 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -23,6 +23,7 @@ import * as Clipboard from 'expo-clipboard'; import Toast from 'react-native-toast-message'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { decode as bolt11Decode } from 'light-bolt11-decoder'; import { useWallet } from '../contexts/WalletContext'; import { useNostr } from '../contexts/NostrContext'; import { walletLabel } from '../types/wallet'; @@ -31,6 +32,46 @@ import { receiveSheetStyles as styles } from '../styles/ReceiveSheet.styles'; import { satsToFiatString, satsToFiat } from '../services/fiatService'; import FriendPickerSheet, { PickedFriend } from './FriendPickerSheet'; import type { RootStackParamList } from '../navigation/types'; + +function paymentHashFromBolt11(bolt11: string): string | null { + try { + const decoded = bolt11Decode(bolt11); + const section = decoded.sections?.find((s: { name: string }) => s.name === 'payment_hash') as + | { value?: string } + | undefined; + return section?.value ?? null; + } catch (error) { + // Silent null returns would mask broken invoice generation; at + // least surface it in dev logs so the fallback-to-balance-poll is + // traceable. + if (__DEV__) console.warn('[Receive] bolt11 decode failed:', error); + return null; + } +} + +// Accept only digit characters — sats are whole integers. A hardware +// keyboard, paste, or an autocomplete suggestion can all inject junk +// that the soft-keyboard's `numeric` hint alone doesn't block. +function sanitizeSatsInput(text: string): string { + return text.replace(/[^0-9]/g, ''); +} + +// Accept digits and a single decimal point, with at most two decimal +// places (standard fiat presentation). Strip everything else. Dropping +// a stray comma / currency symbol on paste is the common reason users +// see "Invalid amount" when they didn't mistype anything. +function sanitizeFiatInput(text: string): string { + let cleaned = text.replace(/[^0-9.]/g, ''); + const firstDot = cleaned.indexOf('.'); + if (firstDot !== -1) { + // Keep the first dot, drop any subsequent ones. + cleaned = cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, ''); + // Trim to two decimal places. + const [intPart, fracPart = ''] = cleaned.split('.'); + cleaned = `${intPart}.${fracPart.slice(0, 2)}`; + } + return cleaned; +} // On-chain address fetching is done via WalletContext.getReceiveAddress interface Props { @@ -60,6 +101,8 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent currency, lightningAddress, getReceiveAddress, + expectPayment, + lastIncomingPayment, } = useWallet(); const [capturedWalletId, setCapturedWalletId] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -73,8 +116,6 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent const [onchainAddress, setOnchainAddress] = useState(null); const [friendPickerOpen, setFriendPickerOpen] = useState(false); const [sendingToFriend, setSendingToFriend] = useState(false); - const intervalId = useRef | null>(null); - const prevBalance = useRef(null); const debounceTimer = useRef | null>(null); const bottomSheetRef = useRef(null); const { sendDirectMessage, contacts } = useNostr(); @@ -93,14 +134,9 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent [wallets, selectedWalletId], ); const walletName = selectedWallet ? walletLabel(selectedWallet) : 'Wallet'; - const balance = selectedWallet?.balance ?? null; const generateInvoice = useCallback( async (sats: number) => { - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } setLoading(true); setPaymentReceived(false); try { @@ -108,16 +144,32 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent if (!wId) return; const inv = await makeInvoiceForWallet(wId, sats, 'Lightning Piggy'); setInvoice(inv); - intervalId.current = setInterval(async () => { - if (wId) await refreshBalanceForWallet(wId); - }, 5000); + + // Hand the invoice off to WalletContext.expectPayment, which + // runs a 1 s lookup_invoice + balance poll for the next 3 min. + // The poll lives in the context (not this sheet), so it keeps + // running even if the user closes the receive sheet and wanders + // off to Friends / Home etc. — the app-root overlay still pops + // on settle regardless of which screen is active. Passing the + // expected amount means the overlay shows the exact invoice + // value rather than a balance-delta that could include prior + // settles piled up between polls. + const paymentHash = paymentHashFromBolt11(inv); + if (paymentHash) { + expectPayment(wId, paymentHash, sats); + } else { + // Unparseable bolt11 — fall back to a single balance refresh. + // The WalletContext 30 s baseline poll still picks the + // settle up eventually if the user lingers in-app. + await refreshBalanceForWallet(wId); + } } catch (error) { console.warn('Failed to create invoice:', error); } finally { setLoading(false); } }, - [makeInvoiceForWallet, refreshBalanceForWallet, capturedWalletId], + [makeInvoiceForWallet, refreshBalanceForWallet, capturedWalletId, expectPayment], ); const isOnchainWallet = selectedWallet?.walletType === 'onchain'; @@ -128,7 +180,10 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent if (visible) { setCapturedWalletId(activeWalletId); setDropdownOpen(false); - prevBalance.current = balance; + // Baseline is set from the first observed balance (see effect + // below), not from the cached value here — the cache may be stale + // if the app has been backgrounded and a previous invoice settled + // while we weren't polling. setOnchainAddress(null); setSatsValue(''); setFiatValue(''); @@ -157,10 +212,10 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent bottomSheetRef.current?.dismiss(); } return () => { - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } + // Poll lives in WalletContext.expectPayment now and survives + // sheet closure (so the user can generate an invoice, close the + // sheet, navigate elsewhere, and still get the celebration). + // Only the debounce timer is sheet-local. if (debounceTimer.current) clearTimeout(debounceTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -184,11 +239,6 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent setPaymentReceived(false); setSatsValue(''); setFiatValue(''); - prevBalance.current = selectedWallet?.balance ?? null; - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } if (selectedWallet?.walletType === 'onchain') { setMode('address'); getReceiveAddress(capturedWalletId) @@ -200,30 +250,28 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // eslint-disable-next-line react-hooks/exhaustive-deps }, [capturedWalletId]); - // Detect payment by watching balance changes + // The "paymentReceived" checkmark on the QR thumbnail flips to true + // whenever the app-root overlay fires for the wallet this sheet is + // currently showing. We used to duplicate the baseline-detector + // logic locally — pointlessly, since WalletContext already owns it. + // Keyed on the event timestamp so a second receive within the same + // sheet session still re-arms the checkmark after the user dismisses + // the global overlay and clears `lastIncomingPayment`. useEffect(() => { + if (!visible) return; if ( - visible && - prevBalance.current !== null && - balance !== null && - balance > prevBalance.current + lastIncomingPayment && + selectedWallet && + lastIncomingPayment.walletId === selectedWallet.id ) { setPaymentReceived(true); - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } } - }, [balance, visible]); + }, [lastIncomingPayment, selectedWallet, visible]); const scheduleInvoice = (sats: number) => { if (debounceTimer.current) clearTimeout(debounceTimer.current); if (sats <= 0) { setInvoice(''); - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } return; } debounceTimer.current = setTimeout(() => { @@ -232,8 +280,9 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent }; const handleSatsChange = (text: string) => { - setSatsValue(text); - const sats = parseInt(text) || 0; + const clean = sanitizeSatsInput(text); + setSatsValue(clean); + const sats = parseInt(clean) || 0; if (btcPrice) { setFiatValue(satsToFiat(sats, btcPrice).toFixed(2)); } else { @@ -243,8 +292,9 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent }; const handleFiatChange = (text: string) => { - setFiatValue(text); - const fiat = parseFloat(text) || 0; + const clean = sanitizeFiatInput(text); + setFiatValue(clean); + const fiat = parseFloat(clean) || 0; const sats = fiatToSats(fiat); setSatsValue(sats.toString()); scheduleInvoice(sats); diff --git a/src/components/SendSheet.tsx b/src/components/SendSheet.tsx index f50a8869..929eaea7 100644 --- a/src/components/SendSheet.tsx +++ b/src/components/SendSheet.tsx @@ -32,6 +32,7 @@ import * as boltzService from '../services/boltzService'; import * as onchainService from '../services/onchainService'; import { npubEncode } from '../services/nostrService'; import { recordOutgoing as recordOutgoingCounterparty } from '../services/zapCounterpartyStorage'; +import PaymentProgressOverlay, { PaymentProgressState } from './PaymentProgressOverlay'; interface Props { visible: boolean; @@ -77,6 +78,25 @@ function isLightningAddress(input: string): boolean { return input.includes('@') && !input.startsWith('lnbc') && !input.startsWith('lntb'); } +// Accept only digits — sats are whole integers. A hardware keyboard, +// paste, or autocomplete can inject junk that the soft-keyboard's +// `numeric` hint alone doesn't block. +function sanitizeSatsInput(text: string): string { + return text.replace(/[^0-9]/g, ''); +} + +// Digits + a single decimal point, max two decimal places. +function sanitizeFiatInput(text: string): string { + let cleaned = text.replace(/[^0-9.]/g, ''); + const firstDot = cleaned.indexOf('.'); + if (firstDot !== -1) { + cleaned = cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, ''); + const [intPart, fracPart = ''] = cleaned.split('.'); + cleaned = `${intPart}.${fracPart.slice(0, 2)}`; + } + return cleaned; +} + function isValidInvoice(data: string): boolean { const lower = data.toLowerCase(); return ( @@ -128,6 +148,8 @@ const SendSheet: React.FC = ({ const [boltzFees, setBoltzFees] = useState(null); const [loadingBoltzFees, setLoadingBoltzFees] = useState(false); const [onchainFeeEstimate, setOnchainFeeEstimate] = useState(null); + const [progressState, setProgressState] = useState('hidden'); + const [progressError, setProgressError] = useState(undefined); const bottomSheetRef = useRef(null); const snapPoints = useMemo(() => ['90%'], []); @@ -340,8 +362,9 @@ const SendSheet: React.FC = ({ }; const handleSatsChange = (text: string) => { - setSatsValue(text); - const sats = parseInt(text) || 0; + const clean = sanitizeSatsInput(text); + setSatsValue(clean); + const sats = parseInt(clean) || 0; if (btcPrice) { setFiatValue(satsToFiat(sats, btcPrice).toFixed(2)); } else { @@ -350,8 +373,9 @@ const SendSheet: React.FC = ({ }; const handleFiatChange = (text: string) => { - setFiatValue(text); - const fiat = parseFloat(text) || 0; + const clean = sanitizeFiatInput(text); + setFiatValue(clean); + const fiat = parseFloat(clean) || 0; const sats = fiatToSats(fiat); setSatsValue(sats.toString()); }; @@ -359,6 +383,8 @@ const SendSheet: React.FC = ({ const handleSend = async () => { if (!invoiceData) return; setSending(true); + setProgressError(undefined); + setProgressState('sending'); try { if (isOnchainAddress) { if (currentSats <= 0) { @@ -511,17 +537,26 @@ const SendSheet: React.FC = ({ } })(); } - Alert.alert('Payment Sent', 'Your payment was sent successfully!', [ - { text: 'OK', onPress: onClose }, - ]); + setProgressState('success'); } catch (error) { const message = error instanceof Error ? error.message : 'Payment failed'; - Alert.alert('Payment Failed', message); + setProgressError(message); + setProgressState('error'); } finally { setSending(false); } }; + const handleOverlayDismiss = useCallback(() => { + // Dismissing the overlay after a successful payment also closes the + // parent sheet. On error we only dismiss the overlay so the user can + // retry from the filled-in form. + const wasSuccess = progressState === 'success'; + setProgressState('hidden'); + setProgressError(undefined); + if (wasSuccess) onClose(); + }, [progressState, onClose]); + const handleReset = () => { setInvoiceData(null); setDecoded(null); @@ -562,317 +597,330 @@ const SendSheet: React.FC = ({ : !!invoiceData; return ( - - - - - Send - - {/* Wallet selector */} - {wallets.filter((w) => w.isConnected).length > 1 ? ( - - From: - + <> + + + + + Send + + {/* Wallet selector */} + {wallets.filter((w) => w.isConnected).length > 1 ? ( + + From: + + setDropdownOpen(!dropdownOpen)} + > + {walletName} + {dropdownOpen ? ( + + ) : ( + + )} + + {dropdownOpen && ( + + {wallets + .filter((w) => w.isConnected) + .map((w) => ( + { + setCapturedWalletId(w.id); + setDropdownOpen(false); + }} + > + + {walletLabel(w)} + + + ))} + + )} + + + ) : ( + From: {walletName} + )} + + {/* Mode tabs */} + {!scanned && ( + setDropdownOpen(!dropdownOpen)} + style={[styles.tab, inputMode === 'scan' && styles.tabActive]} + onPress={() => setInputMode('scan')} > - {walletName} - {dropdownOpen ? ( - + + Scan + + + setInputMode('paste')} + > + + Input + + + + )} + + {/* Scanner or paste input */} + {!scanned ? ( + inputMode === 'scan' ? ( + + {!permission.granted ? ( + + + Camera access needed to scan QR codes + + + Grant Permission + + ) : ( - + )} - - {dropdownOpen && ( - - {wallets - .filter((w) => w.isConnected) - .map((w) => ( - { - setCapturedWalletId(w.id); - setDropdownOpen(false); - }} - > - + ) : ( + + + + + Paste from clipboard + + + Go + + + + ) + ) : ( + /* Invoice/address detected - show details */ + + {activePicture && ( + + )} + {decoded?.description ? ( + {decoded.description} + ) : null} + + {needsAmount ? ( + /* Lightning address or on-chain: show amount input */ + + {resolving ? ( + + ) : lnurlParams || isOnchainAddress ? ( + <> + + + setInputUnit('sats')} + > + + Sats + + + setInputUnit('fiat')} > - {walletLabel(w)} + + {currency} + + + + + {inputUnit === 'sats' + ? btcPrice && currentSats > 0 + ? satsToFiatString(currentSats, btcPrice, currency) + : '' + : currentSats > 0 + ? `${currentSats.toLocaleString()} sats` + : ''} + + {lnurlParams ? ( + + {lnurlParams.minSats.toLocaleString()} –{' '} + {lnurlParams.maxSats.toLocaleString()} sats - - ))} + ) : null} + + ) : null} + ) : decoded?.amountSats !== null && decoded?.amountSats !== undefined ? ( + /* Bolt11 with amount */ + + + {decoded.amountSats.toLocaleString()} sats + + {btcPrice ? ( + + {satsToFiatString(decoded.amountSats, btcPrice, currency)} + + ) : null} + + ) : ( + Amount not specified )} - - - ) : ( - From: {walletName} - )} - {/* Mode tabs */} - {!scanned && ( - + {isOnchainAddress && invoiceData ? ( + + {invoiceData.slice(0, 6)} + {invoiceData.slice(6, -6)} + {invoiceData.slice(-6)} + + ) : isLightningAddress(invoiceData || '') ? ( + {invoiceData} + ) : ( + + {invoiceData} + + )} + + {/* Fee estimate for on-chain addresses */} + {isOnchainAddress && currentSats > 0 && ( + + {selectedWallet?.walletType === 'onchain' && + selectedWallet?.onchainImportMethod === 'mnemonic' + ? (onchainFeeEstimate ?? 'Estimating fee...') + : loadingBoltzFees + ? 'Loading fees...' + : boltzFees + ? `Swap fee: ~${boltzService.calculateSwapFee(currentSats, boltzFees).toLocaleString()} sats \u00B7 ~10-60 min` + : 'Fee estimate unavailable'} + + )} + + {/* Memo / comment field for Lightning address payments */} + {needsAmount && ( + + )} + + + Scan / paste different invoice + + + )} + + {/* Balance */} + {walletBalance !== null && btcPrice !== null && ( + + Balance: {walletBalance.toLocaleString()} sats ( + {satsToFiatString(walletBalance, btcPrice, currency)}) + + )} + + {/* Action buttons */} + setInputMode('scan')} + style={styles.cancelButton} + onPress={() => { + handleReset(); + onClose(); + }} > - - Scan - + Cancel setInputMode('paste')} + style={[styles.sendButton, (!canSend || sending) && styles.sendButtonDisabled]} + onPress={handleSend} + disabled={!canSend || sending} > - - Input - - - - )} - - {/* Scanner or paste input */} - {!scanned ? ( - inputMode === 'scan' ? ( - - {!permission.granted ? ( - - - Camera access needed to scan QR codes - - - Grant Permission - - + {sending ? ( + ) : ( - + Send )} - - ) : ( - - - - - Paste from clipboard - - - Go - - - - ) - ) : ( - /* Invoice/address detected - show details */ - - {activePicture && ( - - )} - {decoded?.description ? ( - {decoded.description} - ) : null} - - {needsAmount ? ( - /* Lightning address or on-chain: show amount input */ - - {resolving ? ( - - ) : lnurlParams || isOnchainAddress ? ( - <> - - - setInputUnit('sats')} - > - - Sats - - - setInputUnit('fiat')} - > - - {currency} - - - - - {inputUnit === 'sats' - ? btcPrice && currentSats > 0 - ? satsToFiatString(currentSats, btcPrice, currency) - : '' - : currentSats > 0 - ? `${currentSats.toLocaleString()} sats` - : ''} - - {lnurlParams ? ( - - {lnurlParams.minSats.toLocaleString()} –{' '} - {lnurlParams.maxSats.toLocaleString()} sats - - ) : null} - - ) : null} - - ) : decoded?.amountSats !== null && decoded?.amountSats !== undefined ? ( - /* Bolt11 with amount */ - - - {decoded.amountSats.toLocaleString()} sats - - {btcPrice ? ( - - {satsToFiatString(decoded.amountSats, btcPrice, currency)} - - ) : null} - - ) : ( - Amount not specified - )} - - {isOnchainAddress && invoiceData ? ( - - {invoiceData.slice(0, 6)} - {invoiceData.slice(6, -6)} - {invoiceData.slice(-6)} - - ) : isLightningAddress(invoiceData || '') ? ( - {invoiceData} - ) : ( - - {invoiceData} - - )} - - {/* Fee estimate for on-chain addresses */} - {isOnchainAddress && currentSats > 0 && ( - - {selectedWallet?.walletType === 'onchain' && - selectedWallet?.onchainImportMethod === 'mnemonic' - ? (onchainFeeEstimate ?? 'Estimating fee...') - : loadingBoltzFees - ? 'Loading fees...' - : boltzFees - ? `Swap fee: ~${boltzService.calculateSwapFee(currentSats, boltzFees).toLocaleString()} sats \u00B7 ~10-60 min` - : 'Fee estimate unavailable'} - - )} - - {/* Memo / comment field for Lightning address payments */} - {needsAmount && ( - - )} - - - Scan / paste different invoice - )} - - {/* Balance */} - {walletBalance !== null && btcPrice !== null && ( - - Balance: {walletBalance.toLocaleString()} sats ( - {satsToFiatString(walletBalance, btcPrice, currency)}) - - )} - - {/* Action buttons */} - - { - handleReset(); - onClose(); - }} - > - Cancel - - - {sending ? ( - - ) : ( - Send - )} - - - - - + + + + + ); }; diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 10fe346f..4a9d35df 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; +import { AppState } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as nwcService from '../services/nwcService'; import * as nostrService from '../services/nostrService'; @@ -14,8 +15,23 @@ import { WalletState, WalletTransaction, ZapCounterpartyInfo, + walletLabel, } from '../types/wallet'; +export interface IncomingPayment { + walletId: string; + amountSats: number; + // Timestamp; also serves as a stable React key for the overlay so a + // second payment with the same amount to the same wallet still + // re-mounts the animation. + at: number; + // Set when detection came via a known invoice hash (expectPayment + // path); null when it came via balance-diff (e.g. lightning-address + // receive). Consumers can use this to distinguish "exactly this + // invoice settled" from "something credited the wallet". + paymentHash: string | null; +} + const USER_NAME_KEY = 'user_display_name'; const CURRENCY_KEY = 'user_fiat_currency'; const LIGHTNING_ADDRESS_KEY = 'lightning_address'; @@ -111,6 +127,41 @@ interface WalletContextType { // On-chain actions getReceiveAddress: (walletId: string) => Promise; + // Incoming payment event bus. Set whenever any connected wallet's + // balance goes UP; consumed by the app-root PaymentProgressOverlay + // so the celebration appears regardless of which screen is active. + lastIncomingPayment: IncomingPayment | null; + clearLastIncomingPayment: () => void; + + /** + * Kick off aggressive 1 s polling for a specific NWC invoice for up + * to `durationMs` (default 3 min). Called by ReceiveSheet when an + * invoice is generated — the poll lives in the context so it survives + * the sheet closing (user can generate an invoice, close the sheet, + * wander into Friends, and still get the confetti pop). + * + * **Replacement semantics:** subsequent calls replace any in-flight + * expectation (only one tracked at a time). This is safe because the + * balance-diff detector still runs independently — so if invoice A's + * expectation is replaced by invoice B before A settles, A's eventual + * balance increment will *still* fire the overlay via the diff path, + * just with worst-case 30 s latency (baseline poll) instead of 1 s. + * + * When `expectedAmountSats` is provided and the lookup reports + * `paid: true`, the overlay uses that exact amount rather than the + * balance-delta heuristic. This matters when two invoices settle + * between polls: the delta would report the combined total, the + * explicit amount reports what *this* invoice was for. + * + * Stops early on detected settlement or after the duration elapses. + */ + expectPayment: ( + walletId: string, + paymentHash: string, + expectedAmountSats?: number, + durationMs?: number, + ) => void; + // Legacy compatibility isConnected: boolean; balance: number | null; @@ -128,7 +179,14 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr const [currency, setCurrencyState] = useState('USD'); const [btcPrice, setBtcPrice] = useState(null); const [lightningAddress, setLightningAddressState] = useState(null); + const [lastIncomingPayment, setLastIncomingPayment] = useState(null); const priceInterval = useRef | null>(null); + // Per-wallet baseline balances. Used to decide whether a balance + // change is an incoming payment (increment) or the local consequence + // of a send / send-like flow (decrement). First observation of a + // wallet seeds its baseline silently — we don't fire for the initial + // balance we happen to observe. + const paymentBaselinesRef = useRef>(new Map()); // Derived state const activeWallet = wallets.find((w) => w.id === activeWalletId) ?? null; @@ -1168,6 +1226,271 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr return onchainService.getNextReceiveAddress(walletId); }, []); + const clearLastIncomingPayment = useCallback(() => setLastIncomingPayment(null), []); + + // In-flight "I'm expecting a payment on this invoice" poll state. + // Only one at a time — a new expectPayment() replaces the previous. + // The balance-diff detector still runs independently, so even if this + // poll is replaced before the first invoice settles, the eventual + // balance increment will still fire the overlay. + const expectedPaymentRef = useRef<{ + walletId: string; + paymentHash: string; + expectedAmountSats: number | null; + interval: ReturnType; + timeout: ReturnType; + // Guards against pile-up on slow backends: if tick N hasn't + // completed by the time tick N+1 fires, we skip N+1. + inFlight: boolean; + } | null>(null); + + const stopExpectedPayment = useCallback(() => { + const current = expectedPaymentRef.current; + if (!current) return; + clearInterval(current.interval); + clearTimeout(current.timeout); + expectedPaymentRef.current = null; + }, []); + + const expectPayment = useCallback( + ( + walletId: string, + paymentHash: string, + expectedAmountSats?: number, + durationMs: number = 3 * 60 * 1000, + ) => { + // Replace any previous expectation — we only track one at a time. + // The balance-diff detector still catches the displaced invoice + // when it settles (just on a slower cadence), so we never drop + // detection entirely; see the expectPayment JSDoc in the context + // interface above. + stopExpectedPayment(); + + const tick = async () => { + const current = expectedPaymentRef.current; + // Pile-up guard: if the previous tick is still in flight (slow + // NWC backend, see #133), skip this interval firing entirely + // rather than stacking N concurrent requests. + if (!current || current.inFlight) return; + current.inFlight = true; + try { + const [lookupResult] = await Promise.allSettled([ + nwcService.lookupInvoice(walletId, paymentHash), + // The balance refresh feeds the generic balance-diff + // detector as a fallback; run it every tick so a flaky + // lookup_invoice path still settles detection. + (async () => { + const b = await nwcService.getBalance(walletId); + if (b !== null) updateWalletInState(walletId, { balance: b }); + })(), + ]); + if ( + lookupResult.status === 'fulfilled' && + lookupResult.value?.paid && + expectedPaymentRef.current?.paymentHash === paymentHash + ) { + if (__DEV__) + console.log( + `[Wallet] expected invoice paid (${paymentHash.slice(0, 12)}…) — stopping poll`, + ); + // Fire with the *known* invoice amount rather than the + // balance delta. Two invoices settling between polls would + // show a combined delta on the generic path; the explicit + // amount is always correct. + if (expectedAmountSats !== undefined && expectedAmountSats > 0) { + // Advance the balance-diff baseline to the current balance + // so the /same/ settle doesn't also fire via the diff path. + const currentBalance = walletsRef.current.find((w) => w.id === walletId)?.balance; + if (currentBalance !== null && currentBalance !== undefined) { + paymentBaselinesRef.current.set(walletId, currentBalance); + } + setLastIncomingPayment({ + walletId, + amountSats: expectedAmountSats, + at: Date.now(), + paymentHash, + }); + } + stopExpectedPayment(); + } + } finally { + const still = expectedPaymentRef.current; + if (still) still.inFlight = false; + } + }; + + const interval = setInterval(tick, 1000); + const timeout = setTimeout(() => { + // Only clear if THIS expectation is still current; a newer one + // may have replaced it already. + if (expectedPaymentRef.current?.interval === interval) { + if (__DEV__) + console.log( + `[Wallet] expected payment poll window expired (${paymentHash.slice(0, 12)}…)`, + ); + stopExpectedPayment(); + } + }, durationMs); + + expectedPaymentRef.current = { + walletId, + paymentHash, + expectedAmountSats: expectedAmountSats ?? null, + interval, + timeout, + inFlight: false, + }; + if (__DEV__) + console.log( + `[Wallet] expecting payment on ${paymentHash.slice(0, 12)}… (${Math.round(durationMs / 1000)} s window, amount=${expectedAmountSats ?? '?'})`, + ); + }, + [stopExpectedPayment, updateWalletInState], + ); + + // Tear down any outstanding expectation when the context unmounts — + // leaked intervals kept polling NWC after a logout / hot-reload. + useEffect(() => { + return () => stopExpectedPayment(); + }, [stopExpectedPayment]); + + // When an incoming payment is detected, also pull the latest + // transaction list for that wallet so the Home / Transactions screens + // show the new tx immediately — not on the user's next manual refresh. + // Separate effect so it reads `fetchTransactionsForWallet` after it's + // defined below without the closure-ordering dance. + useEffect(() => { + if (!lastIncomingPayment) return; + fetchTransactionsForWallet(lastIncomingPayment.walletId).catch(() => { + // Non-fatal: next organic refresh will pick the tx up. + }); + // Intentionally only fire on `lastIncomingPayment` changes; the + // callback identity is stable enough across renders that adding it + // would double-fetch on unrelated renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastIncomingPayment]); + + // Incoming-payment detector. Watches every wallet's balance: the first + // time we see a wallet, we record its balance silently as a baseline; + // any subsequent increase fires a `lastIncomingPayment` event that the + // app-root overlay consumes. Decreases simply re-baseline (a send is + // not a "received payment" event). Because this lives in the context, + // the overlay pops regardless of which screen the user is on when + // money lands — ReceiveSheet being open is no longer required. + useEffect(() => { + const baselines = paymentBaselinesRef.current; + const liveIds = new Set(wallets.map((w) => w.id)); + + // Garbage-collect baselines for wallets that have been removed. + // Without this the Map grows monotonically (e.g. user adds and + // removes wallets repeatedly during onboarding / debugging). + for (const id of baselines.keys()) { + if (!liveIds.has(id)) baselines.delete(id); + } + + for (const wallet of wallets) { + const bal = wallet.balance; + if (bal === null || bal === undefined) continue; + const prev = baselines.get(wallet.id); + if (prev === undefined) { + baselines.set(wallet.id, bal); + continue; + } + if (bal > prev) { + const delta = bal - prev; + baselines.set(wallet.id, bal); + if (__DEV__) + console.log( + `[Wallet] incoming payment detected: +${delta} sats on ${walletLabel(wallet)} (${prev} → ${bal})`, + ); + setLastIncomingPayment({ + walletId: wallet.id, + amountSats: delta, + at: Date.now(), + // Balance-diff path doesn't know which invoice settled — only + // expectPayment can attribute by hash. Lightning-address / + // multi-invoice receives land here. + paymentHash: null, + }); + } else if (bal < prev) { + // Outgoing payment or reorg adjustment — silently rebase. + baselines.set(wallet.id, bal); + } + } + }, [wallets]); + + // Keep the active NWC wallet's balance in rough sync so the global + // overlay pops for *any* incoming payment — not just ones the user + // explicitly asked us to watch via expectPayment. Covers: + // + // - app returns to foreground → one-shot refresh to catch anything + // that arrived while backgrounded. + // - 30 s slow poll while the app is foregrounded → catches + // lightning-address payments that land without an in-app + // invoice-generation trigger. Worst-case latency ~30 s, which + // is acceptable for casual address receives; the expectPayment + // fast poll (1 s for 3 min) takes over when the user is + // *actively* waiting on a specific invoice. + // + // On-chain is skipped — BDK sync is expensive and not safe to run + // every 30 s; #134 tracks the on-chain variant of this coverage. + // True background / app-closed delivery needs OS push (#45). + // Track the active wallet's connection state as an explicit dep so + // the poll starts/stops when a wallet reconnects without the active + // id changing. (Previous version only re-ran on `activeWalletId` + // change and could leave the poll running against a disconnected + // wallet — flagged in PR #135 review.) We still avoid putting the + // full `wallets` array in deps, which would thrash on every balance + // tick. + const activeWalletConnected = + activeWallet?.walletType !== 'onchain' && activeWallet?.isConnected === true; + + useEffect(() => { + if (!activeWalletId || !activeWalletConnected) return; + + const refreshOnce = () => { + // Bail if the wallet has since disconnected — we read through + // `walletsRef` rather than the closure so this is current. + const current = walletsRef.current.find((w) => w.id === activeWalletId); + if (!current || !current.isConnected || current.walletType === 'onchain') return; + nwcService + .getBalance(activeWalletId) + .then((b) => { + if (b !== null) updateWalletInState(activeWalletId, { balance: b }); + }) + .catch(() => {}); + }; + + let interval: ReturnType | null = null; + const startPoll = () => { + if (interval) return; + interval = setInterval(refreshOnce, 30000); + }; + const stopPoll = () => { + if (interval) { + clearInterval(interval); + interval = null; + } + }; + + if (AppState.currentState === 'active') { + refreshOnce(); + startPoll(); + } + const sub = AppState.addEventListener('change', (next) => { + if (next === 'active') { + refreshOnce(); + startPoll(); + } else { + stopPoll(); + } + }); + return () => { + stopPoll(); + sub.remove(); + }; + }, [activeWalletId, activeWalletConnected, updateWalletInState]); + return ( = ({ childr fetchTransactionsForWallet, addPendingTransaction, getReceiveAddress, + lastIncomingPayment, + clearLastIncomingPayment, + expectPayment, isConnected, balance, walletAlias,