From fb128836a3e0f1ddadddc841be2f6f487003c5fc Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 22:50:17 +0100 Subject: [PATCH 01/14] feat(ui): branded payment progress overlay with bubbles + confetti MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bare Alert.alert "Payment Sent" native dialog (white square with no rounding) with a rounded on-brand overlay that also fills the gap between "Send" press and "Sent" — the user now sees a sending state with animated feedback, then a clear success tick. Send flow: - "Sending payment…" spinner + amount + rounded card - Pink bubbles rise from the bottom of the screen behind the card; quadratic stagger over 5s ramps density from sparse → packed - On success, bubbles morph to green and a big green tick swaps in - Auto-dismisses 2.2s after success - On failure, shows red X + error message (user can retry or dismiss) Receive flow: - When the receive sheet detects balance has gone up, the same overlay fires in "receive" mode: on-brand confetti (pinks, blues, purples) cascades behind a "Payment received!" card with the received amount Built with react-native-reanimated (already a dep) — no new packages. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/PaymentProgressOverlay.tsx | 468 ++++++++++++++++ src/components/ReceiveSheet.tsx | 18 + src/components/SendSheet.tsx | 615 +++++++++++----------- 3 files changed, 807 insertions(+), 294 deletions(-) create mode 100644 src/components/PaymentProgressOverlay.tsx diff --git a/src/components/PaymentProgressOverlay.tsx b/src/components/PaymentProgressOverlay.tsx new file mode 100644 index 00000000..25726ec6 --- /dev/null +++ b/src/components/PaymentProgressOverlay.tsx @@ -0,0 +1,468 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { + Modal, + View, + Text, + StyleSheet, + useWindowDimensions, + ActivityIndicator, +} 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 = 90; +// 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; +// Auto-dismiss delay once we hit `success`; gives the user a beat to see +// the tick + the green bubble wave (or confetti burst) before closing. +const SUCCESS_DISMISS_MS = 2200; + +// 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: 10 + Math.random() * 34, + 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; + startXRatio: number; + width: number; + height: number; + color: string; + duration: number; + driftPx: number; + delayMs: number; + spinTurns: number; + opacityPeak: number; +} + +function makeConfettiSpecs(count: number): ConfettiSpec[] { + const specs: ConfettiSpec[] = []; + for (let i = 0; i < count; i++) { + // Staggered launch over ~800ms for a celebratory wave rather than + // a hard burst. Longer than that and it feels sluggish. + const delayMs = Math.random() * 800; + specs.push({ + index: i, + startXRatio: Math.random(), + width: 7 + Math.random() * 6, + height: 10 + Math.random() * 8, + color: CONFETTI_COLORS[i % CONFETTI_COLORS.length], + duration: 2200 + Math.random() * 1600, + driftPx: -60 + Math.random() * 120, + delayMs, + spinTurns: 2 + Math.random() * 4, + opacityPeak: 0.85 + Math.random() * 0.15, + }); + } + 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; + screenWidth: number; + screenHeight: number; +} + +function Confetti({ spec, armed, screenWidth, screenHeight }: ConfettiProps) { + const progress = useSharedValue(0); + + // React to the `armed` shared value flipping 0 → 1 — that kicks off the + // fall animation for this piece with its per-spec stagger. + useAnimatedReaction( + () => armed.value, + (armedNow, armedBefore) => { + if (armedNow === 1 && armedBefore !== 1) { + progress.value = withDelay( + spec.delayMs, + withTiming(1, { duration: spec.duration, easing: Easing.in(Easing.quad) }), + ); + } else if (armedNow === 0) { + cancelAnimation(progress); + progress.value = 0; + } + }, + ); + + const animatedStyle = useAnimatedStyle(() => { + const y = interpolate(progress.value, [0, 1], [-80, screenHeight + 80]); + const xOffset = Math.sin(progress.value * Math.PI * 1.5) * spec.driftPx; + const rotate = `${progress.value * spec.spinTurns * 360}deg`; + const opacity = interpolate( + progress.value, + [0, 0.05, 0.9, 1], + [0, spec.opacityPeak, spec.opacityPeak, 0], + ); + return { + transform: [{ translateX: xOffset }, { translateY: y }, { rotate }], + opacity, + }; + }); + + const baseLeft = spec.startXRatio * screenWidth - spec.width / 2; + + 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, and auto-dismiss on success. + const dismissTimerRef = useRef | null>(null); + 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. + confettiArmed.value = 1; + } + iconScale.value = withSequence( + withTiming(0, { duration: 0 }), + withSpring(1, { damping: 10, stiffness: 220 }), + ); + dismissTimerRef.current = setTimeout(() => { + onDismiss(); + }, SUCCESS_DISMISS_MS); + } 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; + } + return () => { + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + }; + }, [state, direction, colorProgress, iconScale, confettiArmed, onDismiss]); + + 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.'; + } + + return ( + + + + {isReceive + ? confettiSpecs.map((spec) => ( + + )) + : bubbleSpecs.map((spec) => ( + + ))} + + + + {state === 'sending' && ( + + )} + {state === 'success' && ( + + + + )} + {state === 'error' && ( + + + + )} + + {title} + {subtitle ? {subtitle} : 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', + }, +}); diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index 94832485..90ba7d1d 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -30,6 +30,7 @@ import { colors } from '../styles/theme'; import { receiveSheetStyles as styles } from '../styles/ReceiveSheet.styles'; import { satsToFiatString, satsToFiat } from '../services/fiatService'; import FriendPickerSheet, { PickedFriend } from './FriendPickerSheet'; +import PaymentProgressOverlay, { PaymentProgressState } from './PaymentProgressOverlay'; import type { RootStackParamList } from '../navigation/types'; // On-chain address fetching is done via WalletContext.getReceiveAddress @@ -73,6 +74,8 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent const [onchainAddress, setOnchainAddress] = useState(null); const [friendPickerOpen, setFriendPickerOpen] = useState(false); const [sendingToFriend, setSendingToFriend] = useState(false); + const [celebrateState, setCelebrateState] = useState('hidden'); + const [celebrateAmount, setCelebrateAmount] = useState(undefined); const intervalId = useRef | null>(null); const prevBalance = useRef(null); const debounceTimer = useRef | null>(null); @@ -208,6 +211,9 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent balance !== null && balance > prevBalance.current ) { + const delta = balance - prevBalance.current; + setCelebrateAmount(delta > 0 ? delta : undefined); + setCelebrateState('success'); setPaymentReceived(true); if (intervalId.current) { clearInterval(intervalId.current); @@ -216,6 +222,12 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent } }, [balance, visible]); + const handleCelebrateDismiss = useCallback(() => { + setCelebrateState('hidden'); + setCelebrateAmount(undefined); + onClose(); + }, [onClose]); + const scheduleInvoice = (sats: number) => { if (debounceTimer.current) clearTimeout(debounceTimer.current); if (sats <= 0) { @@ -681,6 +693,12 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent title="Send invoice to a friend" subtitle="They'll get an encrypted Nostr DM with a Pay button." /> + ); }; diff --git a/src/components/SendSheet.tsx b/src/components/SendSheet.tsx index f50a8869..76272197 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; @@ -128,6 +129,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%'], []); @@ -359,6 +362,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 +516,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(() => { + // Success auto-dismisses via the overlay's timer — on success we also + // close 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 +576,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 && ( + + setInputMode('scan')} + > + + Scan + + setDropdownOpen(!dropdownOpen)} + style={[styles.tab, inputMode === 'paste' && styles.tabActive]} + onPress={() => setInputMode('paste')} > - {walletName} - {dropdownOpen ? ( - + + 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')} > - {walletLabel(w)} + + 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'} + )} - - - ) : ( - From: {walletName} - )} - {/* Mode tabs */} - {!scanned && ( - + {/* 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 - )} - - - - - + + + + + ); }; From 4596f1689a6da9db9bf793b8415e16b66aec4d2a Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 22:55:05 +0100 Subject: [PATCH 02/14] fix(ui): explicit OK button, radial confetti burst, bigger bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses overlay refinement feedback: - Require user acknowledgement: removed the auto-dismiss timer. The success / error card now shows an explicit "OK" (or "Dismiss") button. If the user wasn't looking at the phone when a receive arrived they still see the outcome whenever they come back. - Radial confetti burst: on receive success, confetti now launches from the card centre outward using a projectile-with-gravity equation (v0·t + ½·g·t²), with random angles across the full circle and a slight upward bias so it reads as an explosion rather than a dribble. A 280 ms delay after state → success lets the card visibly spring in first, so pieces burst from *behind* it. - Bigger bubbles on send: min/max bumped from 10–44 px to 22–68 px so the sending state reads more clearly at a glance. - Confetti sits above the card on the receive flow so pieces visibly fly past the card edges. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/PaymentProgressOverlay.tsx | 177 ++++++++++++++-------- 1 file changed, 112 insertions(+), 65 deletions(-) diff --git a/src/components/PaymentProgressOverlay.tsx b/src/components/PaymentProgressOverlay.tsx index 25726ec6..686b484c 100644 --- a/src/components/PaymentProgressOverlay.tsx +++ b/src/components/PaymentProgressOverlay.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Modal, View, @@ -6,6 +6,7 @@ import { StyleSheet, useWindowDimensions, ActivityIndicator, + TouchableOpacity, } from 'react-native'; import Animated, { useSharedValue, @@ -42,9 +43,6 @@ const CONFETTI_COUNT = 90; // 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; -// Auto-dismiss delay once we hit `success`; gives the user a beat to see -// the tick + the green bubble wave (or confetti burst) before closing. -const SUCCESS_DISMISS_MS = 2200; // On-brand confetti palette — Piggy pink plus blues and purples. const CONFETTI_COLORS = [ @@ -75,7 +73,7 @@ function makeSpecs(count: number, screenHeight: number): BubbleSpec[] { specs.push({ index: i, startXRatio: Math.random(), - size: 10 + Math.random() * 34, + size: 22 + Math.random() * 46, duration: 2400 + Math.random() * 1800, driftPx: -40 + Math.random() * 80, delayMs, @@ -90,12 +88,17 @@ function makeSpecs(count: number, screenHeight: number): BubbleSpec[] { interface ConfettiSpec { index: number; - startXRatio: 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; - driftPx: number; + // Small per-piece launch stagger so the burst feels alive, not mechanical. delayMs: number; spinTurns: number; opacityPeak: number; @@ -104,20 +107,27 @@ interface ConfettiSpec { function makeConfettiSpecs(count: number): ConfettiSpec[] { const specs: ConfettiSpec[] = []; for (let i = 0; i < count; i++) { - // Staggered launch over ~800ms for a celebratory wave rather than - // a hard burst. Longer than that and it feels sluggish. - const delayMs = Math.random() * 800; + // 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, - startXRatio: Math.random(), width: 7 + Math.random() * 6, height: 10 + Math.random() * 8, color: CONFETTI_COLORS[i % CONFETTI_COLORS.length], - duration: 2200 + Math.random() * 1600, - driftPx: -60 + Math.random() * 120, - delayMs, - spinTurns: 2 + Math.random() * 4, - opacityPeak: 0.85 + Math.random() * 0.15, + 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; @@ -180,22 +190,23 @@ function Bubble({ spec, colorProgress, screenWidth, screenHeight }: BubbleProps) interface ConfettiProps { spec: ConfettiSpec; armed: SharedValue; - screenWidth: number; - screenHeight: number; + originX: number; + originY: number; } -function Confetti({ spec, armed, screenWidth, screenHeight }: ConfettiProps) { +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); - // React to the `armed` shared value flipping 0 → 1 — that kicks off the - // fall animation for this piece with its per-spec stagger. useAnimatedReaction( () => armed.value, (armedNow, armedBefore) => { if (armedNow === 1 && armedBefore !== 1) { progress.value = withDelay( spec.delayMs, - withTiming(1, { duration: spec.duration, easing: Easing.in(Easing.quad) }), + withTiming(1, { duration: spec.duration, easing: Easing.linear }), ); } else if (armedNow === 0) { cancelAnimation(progress); @@ -205,22 +216,25 @@ function Confetti({ spec, armed, screenWidth, screenHeight }: ConfettiProps) { ); const animatedStyle = useAnimatedStyle(() => { - const y = interpolate(progress.value, [0, 1], [-80, screenHeight + 80]); - const xOffset = Math.sin(progress.value * Math.PI * 1.5) * spec.driftPx; + // 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.05, 0.9, 1], + [0, 0.06, 0.75, 1], [0, spec.opacityPeak, spec.opacityPeak, 0], ); return { - transform: [{ translateX: xOffset }, { translateY: y }, { rotate }], + transform: [{ translateX: tx }, { translateY: ty }, { rotate }], opacity, }; }); - const baseLeft = spec.startXRatio * screenWidth - spec.width / 2; - return ( | null>(null); + // 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. - confettiArmed.value = 1; + // 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 }), ); - dismissTimerRef.current = setTimeout(() => { - onDismiss(); - }, SUCCESS_DISMISS_MS); } else if (state === 'error') { iconScale.value = withSequence( withTiming(0, { duration: 0 }), @@ -308,13 +322,7 @@ export default function PaymentProgressOverlay({ iconScale.value = 0; confettiArmed.value = 0; } - return () => { - if (dismissTimerRef.current) { - clearTimeout(dismissTimerRef.current); - dismissTimerRef.current = null; - } - }; - }, [state, direction, colorProgress, iconScale, confettiArmed, onDismiss]); + }, [state, direction, colorProgress, iconScale, confettiArmed]); const cardAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: cardScale.value }], @@ -365,27 +373,20 @@ export default function PaymentProgressOverlay({ onRequestClose={state === 'sending' ? undefined : onDismiss} > - - {isReceive - ? confettiSpecs.map((spec) => ( - - )) - : bubbleSpecs.map((spec) => ( - - ))} - + {/* Bubbles live behind the card on the send flow. */} + {!isReceive ? ( + + {bubbleSpecs.map((spec) => ( + + ))} + + ) : null} {state === 'sending' && ( @@ -404,7 +405,38 @@ export default function PaymentProgressOverlay({ {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} + + {/* Confetti sits on top of the card so pieces visibly burst past + * the card edges as they fly radially outward. Origin = screen + * centre, which matches the card's visual centre. */} + {isReceive ? ( + + {confettiSpecs.map((spec) => ( + + ))} + + ) : null} ); @@ -465,4 +497,19 @@ const styles = StyleSheet.create({ 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, + }, }); From 770d703ae81359f616a72c9bcf62d452afb5c508 Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 22:56:48 +0100 Subject: [PATCH 03/14] fix(ui): render confetti behind the receive card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Later siblings stack above earlier ones in React Native. The confetti layer was rendered AFTER the card, so pieces flew over the top of it rather than bursting out from behind. Moved the particle layer to be the first sibling inside the root View — the card now overlays it and confetti visibly erupts from behind the card as intended. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/PaymentProgressOverlay.tsx | 57 +++++++++++------------ 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/src/components/PaymentProgressOverlay.tsx b/src/components/PaymentProgressOverlay.tsx index 686b484c..3ac3d0bd 100644 --- a/src/components/PaymentProgressOverlay.tsx +++ b/src/components/PaymentProgressOverlay.tsx @@ -373,20 +373,32 @@ export default function PaymentProgressOverlay({ onRequestClose={state === 'sending' ? undefined : onDismiss} > - {/* Bubbles live behind the card on the send flow. */} - {!isReceive ? ( - - {bubbleSpecs.map((spec) => ( - - ))} - - ) : null} + {/* 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' && ( @@ -420,23 +432,6 @@ export default function PaymentProgressOverlay({ ) : null} - - {/* Confetti sits on top of the card so pieces visibly burst past - * the card edges as they fly radially outward. Origin = screen - * centre, which matches the card's visual centre. */} - {isReceive ? ( - - {confettiSpecs.map((spec) => ( - - ))} - - ) : null} ); From a23cb121463a1dd3a01b11fb958897babd6012c6 Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 22:58:17 +0100 Subject: [PATCH 04/14] feat(ui): 50% more confetti pieces on receive burst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONFETTI_COUNT 90 → 135. The burst now reads more as a full celebration than a scattered pop, especially at the edges of the screen where trajectories fan out thin. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/PaymentProgressOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PaymentProgressOverlay.tsx b/src/components/PaymentProgressOverlay.tsx index 3ac3d0bd..2162c294 100644 --- a/src/components/PaymentProgressOverlay.tsx +++ b/src/components/PaymentProgressOverlay.tsx @@ -39,7 +39,7 @@ interface Props { } const BUBBLE_COUNT = 140; -const CONFETTI_COUNT = 90; +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; From 162c384c777d942342317ee2d73479cdc6c3324b Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 23:01:40 +0100 Subject: [PATCH 05/14] perf(receive): poll every 1.5s while receive sheet is open (was 5s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receive sheet had a 5 s poll interval, giving up to 5 s of wait on top of the NWC round-trip before we noticed a settled invoice. Dropping to 1.5 s shaves ~3-4 s off the worst-case latency vs. other wallets (e.g. Wallet of Satoshi confirms several seconds faster because it uses a native push/subscription pipeline, not polling). NIP-47 notifications would eliminate polling entirely, but the LNbits NWC provider extension we use (riccardobl/nwcprovider) does not implement kind 23196 `payment_received` / `payment_sent` events — its info event advertises methods but not a notifications capability, and the subscription only listens for 23194/23195. Tracked as follow-up; until then, short-poll is the pragmatic fallback that also covers non-LNbits NWC backends without notification support. The interval is cleared the moment the balance increment is detected or the sheet closes, so battery/network cost is bounded to the window where the user is actively waiting for a payment. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ReceiveSheet.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index 90ba7d1d..5e7c3852 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -111,9 +111,16 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent if (!wId) return; const inv = await makeInvoiceForWallet(wId, sats, 'Lightning Piggy'); setInvoice(inv); + // Poll every 1.5 s while the receive sheet is open. NWC + // notifications (NIP-47) would eliminate the wait entirely, but + // support is patchy across wallets (LNbits, Mutiny, Alby etc.), + // so a short active-poll remains the fallback. Battery cost is + // bounded — the interval is cleared as soon as the sheet closes + // or the balance increment is detected (see the paymentReceived + // handler further down). intervalId.current = setInterval(async () => { if (wId) await refreshBalanceForWallet(wId); - }, 5000); + }, 1500); } catch (error) { console.warn('Failed to create invoice:', error); } finally { From ee05dc258dd72ef18e684dd6afb1962861f19dad Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 23:05:07 +0100 Subject: [PATCH 06/14] fix(receive): stop attributing previous payments to the new invoice Two bugs made the celebration overlay misfire: 1. Baseline was seeded from the cached balance at sheet-open. If a prior invoice settled while the app was backgrounded, the first poll after open would see balance > cached and fire a "received" celebration for an unrelated earlier payment. Fix: ignore the cache and treat the FIRST balance we observe after open (or wallet-switch, or invoice-regenerate) as the baseline. Pending credits from previous invoices get absorbed into the baseline instead of misfiring. 2. After the celebration fired, the interval was cleared and prevBalance was not advanced to the post-credit amount. If the user then created a second invoice without closing the sheet, polling stopped entirely and the real new payment was missed. Fix: on fire, advance prevBalance to the new balance and leave polling running; clearing the interval on payment detection was load-bearing only for the since-removed auto-dismiss flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ReceiveSheet.tsx | 57 +++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index 5e7c3852..4af07d53 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -78,6 +78,13 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent const [celebrateAmount, setCelebrateAmount] = useState(undefined); const intervalId = useRef | null>(null); const prevBalance = useRef(null); + // When true, the next observed balance is treated as the "pre-invoice" + // baseline and is NOT fired as a received payment. Reset on sheet-open, + // wallet-switch, and each new invoice creation — so pending credits + // that settle between app-open and invoice-create get absorbed into + // the baseline instead of firing a bogus celebration attributed to + // the newly-created invoice. + const needsBaseline = useRef(true); const debounceTimer = useRef | null>(null); const bottomSheetRef = useRef(null); const { sendDirectMessage, contacts } = useNostr(); @@ -111,13 +118,16 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent if (!wId) return; const inv = await makeInvoiceForWallet(wId, sats, 'Lightning Piggy'); setInvoice(inv); + // Re-baseline: any pending credit from a previous invoice must + // not fire a celebration attributed to the new one. The first + // balance we see after this poll starts becomes the baseline. + needsBaseline.current = true; // Poll every 1.5 s while the receive sheet is open. NWC // notifications (NIP-47) would eliminate the wait entirely, but // support is patchy across wallets (LNbits, Mutiny, Alby etc.), - // so a short active-poll remains the fallback. Battery cost is - // bounded — the interval is cleared as soon as the sheet closes - // or the balance increment is detected (see the paymentReceived - // handler further down). + // so a short active-poll remains the fallback. Polling continues + // after a celebration fires so subsequent receives still register + // (user could create a second invoice without closing the sheet). intervalId.current = setInterval(async () => { if (wId) await refreshBalanceForWallet(wId); }, 1500); @@ -138,7 +148,12 @@ 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. + prevBalance.current = null; + needsBaseline.current = true; setOnchainAddress(null); setSatsValue(''); setFiatValue(''); @@ -194,7 +209,10 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent setPaymentReceived(false); setSatsValue(''); setFiatValue(''); - prevBalance.current = selectedWallet?.balance ?? null; + // Wallet switch clears the baseline; the next balance observed for + // the new wallet is treated as the pre-invoice starting point. + prevBalance.current = null; + needsBaseline.current = true; if (intervalId.current) { clearInterval(intervalId.current); intervalId.current = null; @@ -210,22 +228,27 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // eslint-disable-next-line react-hooks/exhaustive-deps }, [capturedWalletId]); - // Detect payment by watching balance changes + // Detect payment by watching balance changes. The first balance we + // observe after sheet-open / wallet-switch / invoice-regeneration is + // treated as the baseline — pending credits from prior invoices get + // absorbed here rather than firing a misattributed celebration. On + // fire we advance the baseline to the new balance so another incoming + // payment (second invoice in the same session) still registers, and + // we do NOT stop polling — the overlay stays up until the user taps OK + // but the detector continues to work if they dismiss and invoice again. useEffect(() => { - if ( - visible && - prevBalance.current !== null && - balance !== null && - balance > prevBalance.current - ) { + if (!visible || balance === null) return; + if (needsBaseline.current) { + prevBalance.current = balance; + needsBaseline.current = false; + return; + } + if (prevBalance.current !== null && balance > prevBalance.current) { const delta = balance - prevBalance.current; + prevBalance.current = balance; setCelebrateAmount(delta > 0 ? delta : undefined); setCelebrateState('success'); setPaymentReceived(true); - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } } }, [balance, visible]); From 59e5524b29d268f35e5ef48229f1b5984fced5ba Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 23:11:26 +0100 Subject: [PATCH 07/14] =?UTF-8?q?feat(receive):=20global=20incoming-paymen?= =?UTF-8?q?t=20overlay=20=E2=80=94=20fires=20on=20any=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payment-received celebration was previously rendered inside ReceiveSheet, so it only fired when the user had that sheet open with an amount-specific invoice generated. A payment to the user's lightning address while on Home, or a zap landing while chatting in Conversation, silently landed without a confetti pop. Hoist the detection + overlay render up to the app root: - WalletContext maintains per-wallet balance baselines in a ref and exposes \`lastIncomingPayment\` on the context. Any balance increase on any connected wallet fires the event with \`{ walletId, amountSats, walletAlias }\`. Decrements (sends) silently re-baseline — a send is not a "received" event. - App.tsx mounts a single \`GlobalIncomingPaymentOverlay\` that reads the event and renders \`PaymentProgressOverlay\` over whatever screen is active. The existing ReceiveSheet-specific overlay is removed. - WalletContext also runs a 10 s foreground poll of the active wallet's balance so lightning-address payments get noticed even when the user isn't on ReceiveSheet. The existing 1.5 s ReceiveSheet poll stays for fast in-flow detection; on-chain is skipped (BDK sync is expensive and runs on its own cadence). Paused via AppState when backgrounded. Out of scope here: OS push notifications for payments that arrive while the app is backgrounded or the phone is locked — that needs FCM / APNs plus a backend relay, tracked in #45. Co-Authored-By: Claude Opus 4.7 (1M context) --- App.tsx | 20 +++++- src/components/ReceiveSheet.tsx | 31 ++------- src/contexts/WalletContext.tsx | 107 ++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 27 deletions(-) diff --git a/App.tsx b/App.tsx index 3b36c822..5bb64bb2 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,22 @@ 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(); + return ( + + ); +} + export default function App() { return ( @@ -53,6 +70,7 @@ export default function App() { + diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index 4af07d53..a5071b1a 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -30,7 +30,6 @@ import { colors } from '../styles/theme'; import { receiveSheetStyles as styles } from '../styles/ReceiveSheet.styles'; import { satsToFiatString, satsToFiat } from '../services/fiatService'; import FriendPickerSheet, { PickedFriend } from './FriendPickerSheet'; -import PaymentProgressOverlay, { PaymentProgressState } from './PaymentProgressOverlay'; import type { RootStackParamList } from '../navigation/types'; // On-chain address fetching is done via WalletContext.getReceiveAddress @@ -74,8 +73,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 [celebrateState, setCelebrateState] = useState('hidden'); - const [celebrateAmount, setCelebrateAmount] = useState(undefined); const intervalId = useRef | null>(null); const prevBalance = useRef(null); // When true, the next observed balance is treated as the "pre-invoice" @@ -228,14 +225,11 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // eslint-disable-next-line react-hooks/exhaustive-deps }, [capturedWalletId]); - // Detect payment by watching balance changes. The first balance we - // observe after sheet-open / wallet-switch / invoice-regeneration is - // treated as the baseline — pending credits from prior invoices get - // absorbed here rather than firing a misattributed celebration. On - // fire we advance the baseline to the new balance so another incoming - // payment (second invoice in the same session) still registers, and - // we do NOT stop polling — the overlay stays up until the user taps OK - // but the detector continues to work if they dismiss and invoice again. + // The "paymentReceived" checkmark on the QR thumbnail is driven by + // any balance increment while the sheet is open. The actual + // celebration overlay (confetti + tick card) is now rendered at app + // root via WalletContext.lastIncomingPayment — that way it pops on + // any screen, for any wallet, not just while this sheet is visible. useEffect(() => { if (!visible || balance === null) return; if (needsBaseline.current) { @@ -244,20 +238,11 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent return; } if (prevBalance.current !== null && balance > prevBalance.current) { - const delta = balance - prevBalance.current; prevBalance.current = balance; - setCelebrateAmount(delta > 0 ? delta : undefined); - setCelebrateState('success'); setPaymentReceived(true); } }, [balance, visible]); - const handleCelebrateDismiss = useCallback(() => { - setCelebrateState('hidden'); - setCelebrateAmount(undefined); - onClose(); - }, [onClose]); - const scheduleInvoice = (sats: number) => { if (debounceTimer.current) clearTimeout(debounceTimer.current); if (sats <= 0) { @@ -723,12 +708,6 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent title="Send invoice to a friend" subtitle="They'll get an encrypted Nostr DM with a Pay button." /> - ); }; diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 10fe346f..1f65ca7b 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,16 @@ import { WalletState, WalletTransaction, ZapCounterpartyInfo, + walletLabel, } from '../types/wallet'; +export interface IncomingPayment { + walletId: string; + amountSats: number; + walletAlias: string; + at: number; +} + const USER_NAME_KEY = 'user_display_name'; const CURRENCY_KEY = 'user_fiat_currency'; const LIGHTNING_ADDRESS_KEY = 'lightning_address'; @@ -111,6 +120,12 @@ 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; + // Legacy compatibility isConnected: boolean; balance: number | null; @@ -128,7 +143,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 +1190,89 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr return onchainService.getNextReceiveAddress(walletId); }, []); + const clearLastIncomingPayment = useCallback(() => setLastIncomingPayment(null), []); + + // 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; + 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); + setLastIncomingPayment({ + walletId: wallet.id, + amountSats: delta, + walletAlias: walletLabel(wallet), + at: Date.now(), + }); + } else if (bal < prev) { + // Outgoing payment or reorg adjustment — silently rebase. + baselines.set(wallet.id, bal); + } + } + }, [wallets]); + + // Foreground polling for the active wallet. ReceiveSheet runs its own + // 1.5s poll while an invoice is pending (for fast detection); this + // slower 10s baseline covers the case where the user is *not* on the + // receive sheet — e.g. sitting on Home when someone pays their + // lightning address. On-chain wallets are skipped here; BDK sync is + // expensive and runs on its own cadence elsewhere. + // NOTE: background / app-closed delivery is a separate concern — OS + // push requires FCM/APNs + a backend, tracked in issue #45. + useEffect(() => { + if (!activeWalletId) return; + const wallet = wallets.find((w) => w.id === activeWalletId); + if (!wallet || wallet.walletType === 'onchain' || !wallet.isConnected) return; + + let interval: ReturnType | null = null; + const start = () => { + if (interval) return; + interval = setInterval(() => { + // Fire-and-forget: failures are non-fatal (next tick retries) + // and must not leak unhandled promise rejections. + nwcService + .getBalance(activeWalletId) + .then((b) => { + if (b !== null) updateWalletInState(activeWalletId, { balance: b }); + }) + .catch(() => {}); + }, 10000); + }; + const stop = () => { + if (interval) { + clearInterval(interval); + interval = null; + } + }; + if (AppState.currentState === 'active') start(); + const sub = AppState.addEventListener('change', (next) => { + if (next === 'active') start(); + else stop(); + }); + return () => { + stop(); + sub.remove(); + }; + // `wallets` is intentionally omitted so we don't thrash the + // interval on every balance tick — we only re-wire when the active + // wallet itself changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeWalletId, updateWalletInState]); + return ( = ({ childr fetchTransactionsForWallet, addPendingTransaction, getReceiveAddress, + lastIncomingPayment, + clearLastIncomingPayment, isConnected, balance, walletAlias, From ddd5afa8d61e7e4448c55aafb0ab4d252bd0801d Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 23:12:48 +0100 Subject: [PATCH 08/14] perf(receive): downshift fast poll to WalletContext baseline after 3 min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1.5 s ReceiveSheet poll ran forever while the sheet was open, so a user who generated an invoice and walked away kept a steady 1.5 s network heartbeat going — battery and relay traffic for nothing. Now: after 3 minutes we clear the aggressive interval, and the global WalletContext 10 s foreground poll continues to catch payments from then on (just slightly slower). Most lightning invoices settle within seconds so this window is comfortably long for the happy path, and the fallback means a late payment still pops the celebration — just on the 10 s cadence instead of 1.5 s. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ReceiveSheet.tsx | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index a5071b1a..c7cf3221 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -74,6 +74,10 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent const [friendPickerOpen, setFriendPickerOpen] = useState(false); const [sendingToFriend, setSendingToFriend] = useState(false); const intervalId = useRef | null>(null); + // Timer that downshifts the poll cadence once the user has been + // actively waiting for a payment for a while. Avoids running a 1.5 s + // poll forever if the user leaves the sheet open and walks away. + const pollDownshiftTimer = useRef | null>(null); const prevBalance = useRef(null); // When true, the next observed balance is treated as the "pre-invoice" // baseline and is NOT fired as a received payment. Reset on sheet-open, @@ -119,15 +123,26 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // not fire a celebration attributed to the new one. The first // balance we see after this poll starts becomes the baseline. needsBaseline.current = true; - // Poll every 1.5 s while the receive sheet is open. NWC - // notifications (NIP-47) would eliminate the wait entirely, but - // support is patchy across wallets (LNbits, Mutiny, Alby etc.), - // so a short active-poll remains the fallback. Polling continues - // after a celebration fires so subsequent receives still register - // (user could create a second invoice without closing the sheet). + // Poll every 1.5 s for the first 3 minutes after invoice + // creation — that's the window the user is actively waiting for + // payment. After that, stop the aggressive poll: the global + // WalletContext poll (every 10 s while foreground) still catches + // payments that come in later, just a bit slower. Prevents + // forever-1.5 s battery/network drain if the user leaves the + // sheet open and walks away. + if (pollDownshiftTimer.current) clearTimeout(pollDownshiftTimer.current); intervalId.current = setInterval(async () => { if (wId) await refreshBalanceForWallet(wId); }, 1500); + pollDownshiftTimer.current = setTimeout( + () => { + if (intervalId.current) { + clearInterval(intervalId.current); + intervalId.current = null; + } + }, + 3 * 60 * 1000, + ); } catch (error) { console.warn('Failed to create invoice:', error); } finally { @@ -183,6 +198,10 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent clearInterval(intervalId.current); intervalId.current = null; } + if (pollDownshiftTimer.current) { + clearTimeout(pollDownshiftTimer.current); + pollDownshiftTimer.current = null; + } if (debounceTimer.current) clearTimeout(debounceTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps From d4fae39c4158fc7ea887793db2619a47a7b8f709 Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 23:15:13 +0100 Subject: [PATCH 09/14] =?UTF-8?q?perf(wallet):=20drop=20continuous=2010s?= =?UTF-8?q?=20baseline=20poll=20=E2=80=94=20refresh=20on=20resume=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 10 s foreground poll against NWC burns ~360 relay round-trips per hour per user — not a sensible baseline for the "no payment expected" common case. Replace with a single refresh whenever AppState flips to 'active', which catches anything that arrived while the app was backgrounded at near-zero steady-state cost. Detection coverage after this change: - Actively waiting (ReceiveSheet open, invoice created): 1.5 s poll for up to 3 min (unchanged). - App resumed from background: one-shot refresh on the foreground transition — enough to pop the celebration for anything that settled while the phone was away. - Organic refreshes (pull-to-refresh, post-send sync, tab switches): still feed the detector — whenever balance increments, overlay fires. Lightning-address / zap / on-chain receives that land while the app is in foreground on a non-ReceiveSheet screen and with no other refresh triggered are now detected on the user's next organic interaction rather than within 10 s. Acceptable trade — true background delivery needs OS push (issue #45) regardless. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ReceiveSheet.tsx | 6 ++-- src/contexts/WalletContext.tsx | 60 +++++++++++++-------------------- 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index c7cf3221..8a8abe46 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -125,9 +125,9 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent needsBaseline.current = true; // Poll every 1.5 s for the first 3 minutes after invoice // creation — that's the window the user is actively waiting for - // payment. After that, stop the aggressive poll: the global - // WalletContext poll (every 10 s while foreground) still catches - // payments that come in later, just a bit slower. Prevents + // payment. After that the aggressive poll stops; a late-arriving + // payment is still detected by any organic balance refresh + // (pull-to-refresh, send, app foreground-resume, etc.). Prevents // forever-1.5 s battery/network drain if the user leaves the // sheet open and walks away. if (pollDownshiftTimer.current) clearTimeout(pollDownshiftTimer.current); diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 1f65ca7b..f0e5f6e1 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -1225,51 +1225,39 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr } }, [wallets]); - // Foreground polling for the active wallet. ReceiveSheet runs its own - // 1.5s poll while an invoice is pending (for fast detection); this - // slower 10s baseline covers the case where the user is *not* on the - // receive sheet — e.g. sitting on Home when someone pays their - // lightning address. On-chain wallets are skipped here; BDK sync is - // expensive and runs on its own cadence elsewhere. - // NOTE: background / app-closed delivery is a separate concern — OS - // push requires FCM/APNs + a backend, tracked in issue #45. + // Refresh the active wallet's balance once whenever the app returns + // to the foreground. Cheap (one NWC round-trip per resume) and + // catches payments that arrived while the app was backgrounded — the + // balance diff then fires the global celebration overlay. We + // deliberately do NOT run a continuous baseline poll: the + // ReceiveSheet's 1.5 s window covers the "actively waiting" case, + // and any other organic refresh (pull-to-refresh, tab swap, invoice + // creation, send flow) will also trigger the detector. Burning a + // network request every 10 s just in case a lightning-address + // payment lands isn't worth the battery for most users. + // NOTE: true background / app-closed delivery needs OS push + // (FCM/APNs + backend relay), tracked in issue #45. useEffect(() => { if (!activeWalletId) return; const wallet = wallets.find((w) => w.id === activeWalletId); if (!wallet || wallet.walletType === 'onchain' || !wallet.isConnected) return; - let interval: ReturnType | null = null; - const start = () => { - if (interval) return; - interval = setInterval(() => { - // Fire-and-forget: failures are non-fatal (next tick retries) - // and must not leak unhandled promise rejections. - nwcService - .getBalance(activeWalletId) - .then((b) => { - if (b !== null) updateWalletInState(activeWalletId, { balance: b }); - }) - .catch(() => {}); - }, 10000); + const refreshOnce = () => { + nwcService + .getBalance(activeWalletId) + .then((b) => { + if (b !== null) updateWalletInState(activeWalletId, { balance: b }); + }) + .catch(() => {}); }; - const stop = () => { - if (interval) { - clearInterval(interval); - interval = null; - } - }; - if (AppState.currentState === 'active') start(); + const sub = AppState.addEventListener('change', (next) => { - if (next === 'active') start(); - else stop(); + if (next === 'active') refreshOnce(); }); - return () => { - stop(); - sub.remove(); - }; + return () => sub.remove(); // `wallets` is intentionally omitted so we don't thrash the - // interval on every balance tick — we only re-wire when the active - // wallet itself changes. + // subscription on every balance tick — we only re-wire when the + // active wallet itself changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeWalletId, updateWalletInState]); From fd6d46243d9483f6fa3906df8f0b68bd31274646 Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 23:20:50 +0100 Subject: [PATCH 10/14] perf(receive): targeted lookup_invoice polling for faster detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closer the gap vs. WoS by polling the SPECIFIC invoice's settled state via NIP-47 \`lookup_invoice\` rather than polling wallet-wide balance: - LNbits flips \`settled_at\` the instant LND signals settle — no wait for balance aggregation / cache propagation. - Targeted request (one invoice) vs. balance roll-up (full wallet totals) — lighter and less prone to slow responses on busy backends. - On settle, fall through to a one-shot \`refreshBalanceForWallet\` call so the WalletContext diff-detector still fires the overlay — single source of truth for "balance went up ⇒ celebrate". - Poll cadence tightened 1500 ms → 1000 ms (lookup_invoice is a lighter request than get_balance on most backends). - Fallback to balance polling retained for the rare case where the bolt11 doesn't yield an extractable payment_hash. Remaining gap vs. WoS is architectural: WoS talks to its own LN node directly with push/subscription; we go through LNbits + NWC + a Nostr relay, so there's always some round-trip floor. The remaining cure-all is NIP-47 notifications (tracked: lnbits/nwcprovider#30). Also restrict amount inputs in Send and Receive sheets to digits only (sats) or digits + single decimal (fiat). Hardware keyboards, paste, and autocomplete can bypass the soft-keyboard numeric hint and inject junk characters that caused silent "Invalid amount" rejections. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ReceiveSheet.tsx | 89 ++++++++++++++++++++++++++++----- src/components/SendSheet.tsx | 29 +++++++++-- 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index 8a8abe46..9278fd08 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -23,14 +23,52 @@ 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'; import { colors } from '../styles/theme'; import { receiveSheetStyles as styles } from '../styles/ReceiveSheet.styles'; import { satsToFiatString, satsToFiat } from '../services/fiatService'; +import * as nwcService from '../services/nwcService'; 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 { + 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 { @@ -123,17 +161,40 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // not fire a celebration attributed to the new one. The first // balance we see after this poll starts becomes the baseline. needsBaseline.current = true; - // Poll every 1.5 s for the first 3 minutes after invoice - // creation — that's the window the user is actively waiting for - // payment. After that the aggressive poll stops; a late-arriving - // payment is still detected by any organic balance refresh - // (pull-to-refresh, send, app foreground-resume, etc.). Prevents - // forever-1.5 s battery/network drain if the user leaves the - // sheet open and walks away. + + // Targeted invoice polling: ask the NWC backend directly whether + // THIS invoice is settled (`lookup_invoice`) rather than polling + // the wallet-wide balance. LNbits flips the invoice's settled_at + // the moment LND signals settle — no need to wait for balance + // aggregation. Faster than get_balance on most backends and + // race-free (we get definitive paid/unpaid per invoice hash). + // Falls through to refreshBalanceForWallet on settle so the + // detector in WalletContext picks it up and fires the overlay. + const paymentHash = paymentHashFromBolt11(inv); if (pollDownshiftTimer.current) clearTimeout(pollDownshiftTimer.current); intervalId.current = setInterval(async () => { - if (wId) await refreshBalanceForWallet(wId); - }, 1500); + if (!wId) return; + if (paymentHash) { + const result = await nwcService.lookupInvoice(wId, paymentHash); + if (result?.paid) { + if (intervalId.current) { + clearInterval(intervalId.current); + intervalId.current = null; + } + // Trigger a balance refresh so the diff-detector in + // WalletContext fires the overlay. Wallet balance lags the + // invoice-settled flag by at most a tick. + await refreshBalanceForWallet(wId); + return; + } + } else { + // No extractable payment_hash — fall back to balance polling. + await refreshBalanceForWallet(wId); + } + }, 1000); + // Stop the aggressive poll after 3 min. A late-arriving payment + // is still detected by any organic refresh (pull-to-refresh, + // foreground resume, etc.) — see WalletContext baseline logic. pollDownshiftTimer.current = setTimeout( () => { if (intervalId.current) { @@ -278,8 +339,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 { @@ -289,8 +351,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 76272197..40c0ff25 100644 --- a/src/components/SendSheet.tsx +++ b/src/components/SendSheet.tsx @@ -78,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 ( @@ -343,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 { @@ -353,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()); }; From e4f9739d5880d1e31336ee7ef4cf14e263190f5c Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 23:25:14 +0100 Subject: [PATCH 11/14] fix(receive): parallel lookup + balance poll, dev logs for detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payment-received detection was silent when the NWC backend (LNbits in our case) was slow to answer get_balance — requests timed out and the WalletContext diff-detector never saw the new balance. The previous implementation early-returned from the lookup-only path, so the balance refresh never got a chance to land. Now we fire BOTH on every tick: - \`lookup_invoice\` against the specific payment hash (fastest signal) - \`refreshBalanceForWallet\` which feeds the global diff-detector \`Promise.allSettled\` so one failing doesn't block the other. Whichever succeeds first drives detection. The lookup's \`paid=true\` response still stops the poll loop early. Add dev-mode logs: - \`[Receive] starting 1 s poll · paymentHash=…\` at generateInvoice - \`[Receive] lookup_invoice reports PAID — stopping poll\` on settle - \`[Wallet] incoming payment detected: +X sats on …\` in the context diff-detector Makes it obvious from logcat whether bolt11 decoding yielded a hash, whether lookup_invoice is firing, and whether the detector actually saw a delta. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ReceiveSheet.tsx | 47 +++++++++++++++++---------------- src/contexts/WalletContext.tsx | 4 +++ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index 9278fd08..990b8e23 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -162,34 +162,35 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // balance we see after this poll starts becomes the baseline. needsBaseline.current = true; - // Targeted invoice polling: ask the NWC backend directly whether - // THIS invoice is settled (`lookup_invoice`) rather than polling - // the wallet-wide balance. LNbits flips the invoice's settled_at - // the moment LND signals settle — no need to wait for balance - // aggregation. Faster than get_balance on most backends and - // race-free (we get definitive paid/unpaid per invoice hash). - // Falls through to refreshBalanceForWallet on settle so the - // detector in WalletContext picks it up and fires the overlay. + // Belt-and-suspenders poll: on every tick we fire BOTH a + // `lookup_invoice` (targeted per-hash check — fastest signal + // when paid flips, independent of balance aggregation) AND a + // `refreshBalanceForWallet` (which ultimately lands in the + // WalletContext diff-detector that renders the global overlay). + // Running both means a flaky NWC backend — `get_balance` is + // observed to time out on our LNbits occasionally — still + // has a fallback path: whichever returns first wins. The + // balance refresh is the one that actually fires the overlay, + // so don't early-return from the lookup path; let the balance + // diff drive detection. const paymentHash = paymentHashFromBolt11(inv); + if (__DEV__) + console.log( + `[Receive] starting 1 s poll · paymentHash=${paymentHash ? paymentHash.slice(0, 12) + '…' : 'null (fallback to balance)'}`, + ); if (pollDownshiftTimer.current) clearTimeout(pollDownshiftTimer.current); intervalId.current = setInterval(async () => { if (!wId) return; - if (paymentHash) { - const result = await nwcService.lookupInvoice(wId, paymentHash); - if (result?.paid) { - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } - // Trigger a balance refresh so the diff-detector in - // WalletContext fires the overlay. Wallet balance lags the - // invoice-settled flag by at most a tick. - await refreshBalanceForWallet(wId); - return; + const [lookupResult] = await Promise.allSettled([ + paymentHash ? nwcService.lookupInvoice(wId, paymentHash) : Promise.resolve(null), + refreshBalanceForWallet(wId), + ]); + if (lookupResult.status === 'fulfilled' && lookupResult.value?.paid) { + if (__DEV__) console.log('[Receive] lookup_invoice reports PAID — stopping poll'); + if (intervalId.current) { + clearInterval(intervalId.current); + intervalId.current = null; } - } else { - // No extractable payment_hash — fall back to balance polling. - await refreshBalanceForWallet(wId); } }, 1000); // Stop the aggressive poll after 3 min. A late-arriving payment diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index f0e5f6e1..3f776442 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -1212,6 +1212,10 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr 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, From 5df4b35be9f10c4e054e584d352f15053f2ea5fc Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 23:41:10 +0100 Subject: [PATCH 12/14] feat(receive): refresh tx list automatically when incoming payment fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The balance diff detector flipped \`lastIncomingPayment\` (driving the confetti overlay) but did not touch the wallet's cached transaction list. After dismissing the overlay the new tx wouldn't appear on Home or the Transactions screen until the user manually pull-to-refreshed or triggered something else that ran \`fetchTransactionsForWallet\`. Now a dedicated effect watches \`lastIncomingPayment\` and triggers a tx-list refresh on the affected wallet as soon as detection fires — so by the time the user taps OK on the celebration, the transaction row is already there. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/contexts/WalletContext.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 3f776442..7878a17d 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -1192,6 +1192,22 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr const clearLastIncomingPayment = useCallback(() => setLastIncomingPayment(null), []); + // 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 From 094f54a59041b9b18ed840ddca379dcc2022e956 Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Tue, 21 Apr 2026 23:47:44 +0100 Subject: [PATCH 13/14] feat(receive): fast-poll survives sheet close + lightning-address coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related improvements to incoming-payment detection: 1. The 1 s fast poll for a specific invoice is now owned by the WalletContext via a new \`expectPayment(walletId, paymentHash)\` API. ReceiveSheet calls it when an invoice is generated, then doesn't care whether the sheet stays mounted — the poll lives on the provider and survives sheet-close, navigation, wallet switching the receive sheet, etc. User flow enabled: generate invoice → share QR with a friend → close the sheet → go chat in Friends → when the friend pays, the confetti + "Payment received!" card still pops, globally. Stops early on \`paid:true\` from \`lookup_invoice\`, caps at 3 minutes, and a subsequent \`expectPayment\` replaces the in-flight expectation (one at a time; the balance-diff detector still catches the displaced invoice when it eventually settles). 2. Re-introduce a slow (30 s) baseline poll for the active NWC wallet while the app is foregrounded. Previously removed as wasteful, but necessary to fire the overlay on lightning-address payments that arrive without an in-app invoice-generation trigger. Worst-case latency ~30 s vs. 1 s during active wait — acceptable for casual address receives. Paused on background via AppState. Net coverage summary: - ReceiveSheet-pending invoice, anywhere in app: 1 s (expectPayment) - Lightning-address receive while foregrounded, anywhere: ≤30 s - App resume from background: one-shot refresh on foreground transition - On-chain: still not covered, tracked in #134 - App closed / phone locked: needs OS push (#45) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ReceiveSheet.tsx | 89 +++++--------------- src/contexts/WalletContext.tsx | 139 ++++++++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 83 deletions(-) diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index 990b8e23..570e8292 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -30,7 +30,6 @@ import { walletLabel } from '../types/wallet'; import { colors } from '../styles/theme'; import { receiveSheetStyles as styles } from '../styles/ReceiveSheet.styles'; import { satsToFiatString, satsToFiat } from '../services/fiatService'; -import * as nwcService from '../services/nwcService'; import FriendPickerSheet, { PickedFriend } from './FriendPickerSheet'; import type { RootStackParamList } from '../navigation/types'; @@ -98,6 +97,7 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent currency, lightningAddress, getReceiveAddress, + expectPayment, } = useWallet(); const [capturedWalletId, setCapturedWalletId] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -111,11 +111,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); - // Timer that downshifts the poll cadence once the user has been - // actively waiting for a payment for a while. Avoids running a 1.5 s - // poll forever if the user leaves the sheet open and walks away. - const pollDownshiftTimer = useRef | null>(null); const prevBalance = useRef(null); // When true, the next observed balance is treated as the "pre-invoice" // baseline and is NOT fired as a received payment. Reset on sheet-open, @@ -146,10 +141,6 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent const generateInvoice = useCallback( async (sats: number) => { - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } setLoading(true); setPaymentReceived(false); try { @@ -162,56 +153,28 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // balance we see after this poll starts becomes the baseline. needsBaseline.current = true; - // Belt-and-suspenders poll: on every tick we fire BOTH a - // `lookup_invoice` (targeted per-hash check — fastest signal - // when paid flips, independent of balance aggregation) AND a - // `refreshBalanceForWallet` (which ultimately lands in the - // WalletContext diff-detector that renders the global overlay). - // Running both means a flaky NWC backend — `get_balance` is - // observed to time out on our LNbits occasionally — still - // has a fallback path: whichever returns first wins. The - // balance refresh is the one that actually fires the overlay, - // so don't early-return from the lookup path; let the balance - // diff drive detection. + // 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. const paymentHash = paymentHashFromBolt11(inv); - if (__DEV__) - console.log( - `[Receive] starting 1 s poll · paymentHash=${paymentHash ? paymentHash.slice(0, 12) + '…' : 'null (fallback to balance)'}`, - ); - if (pollDownshiftTimer.current) clearTimeout(pollDownshiftTimer.current); - intervalId.current = setInterval(async () => { - if (!wId) return; - const [lookupResult] = await Promise.allSettled([ - paymentHash ? nwcService.lookupInvoice(wId, paymentHash) : Promise.resolve(null), - refreshBalanceForWallet(wId), - ]); - if (lookupResult.status === 'fulfilled' && lookupResult.value?.paid) { - if (__DEV__) console.log('[Receive] lookup_invoice reports PAID — stopping poll'); - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } - } - }, 1000); - // Stop the aggressive poll after 3 min. A late-arriving payment - // is still detected by any organic refresh (pull-to-refresh, - // foreground resume, etc.) — see WalletContext baseline logic. - pollDownshiftTimer.current = setTimeout( - () => { - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } - }, - 3 * 60 * 1000, - ); + if (paymentHash) { + expectPayment(wId, paymentHash); + } 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'; @@ -256,14 +219,10 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent bottomSheetRef.current?.dismiss(); } return () => { - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } - if (pollDownshiftTimer.current) { - clearTimeout(pollDownshiftTimer.current); - pollDownshiftTimer.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 @@ -291,10 +250,6 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // the new wallet is treated as the pre-invoice starting point. prevBalance.current = null; needsBaseline.current = true; - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } if (selectedWallet?.walletType === 'onchain') { setMode('address'); getReceiveAddress(capturedWalletId) @@ -328,10 +283,6 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent if (debounceTimer.current) clearTimeout(debounceTimer.current); if (sats <= 0) { setInvoice(''); - if (intervalId.current) { - clearInterval(intervalId.current); - intervalId.current = null; - } return; } debounceTimer.current = setTimeout(() => { diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 7878a17d..5d8a6110 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -126,6 +126,15 @@ interface WalletContextType { lastIncomingPayment: IncomingPayment | null; clearLastIncomingPayment: () => void; + // Kick off aggressive 1 s polling for a specific NWC invoice for the + // next 3 minutes. 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). Subsequent calls + // replace any in-flight expectation. Stops early on detected + // settlement or after the duration elapses. + expectPayment: (walletId: string, paymentHash: string, durationMs?: number) => void; + // Legacy compatibility isConnected: boolean; balance: number | null; @@ -1192,6 +1201,79 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr 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; + interval: ReturnType; + timeout: ReturnType; + } | 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, durationMs: number = 3 * 60 * 1000) => { + // Replace any previous expectation — we only track one at a time. + stopExpectedPayment(); + + const tick = async () => { + const [lookupResult] = await Promise.allSettled([ + nwcService.lookupInvoice(walletId, paymentHash), + // The balance refresh is what ultimately drives the + // WalletContext diff-detector and fires the overlay; fire + // both every tick so either path catches the settle. + (async () => { + const b = await nwcService.getBalance(walletId); + if (b !== null) updateWalletInState(walletId, { balance: b }); + })(), + ]); + if (lookupResult.status === 'fulfilled' && lookupResult.value?.paid) { + if (__DEV__) + console.log( + `[Wallet] expected invoice paid (${paymentHash.slice(0, 12)}…) — stopping poll`, + ); + stopExpectedPayment(); + } + }; + + 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, interval, timeout }; + if (__DEV__) + console.log( + `[Wallet] expecting payment on ${paymentHash.slice(0, 12)}… (${Math.round(durationMs / 1000)} s window)`, + ); + }, + [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. @@ -1245,18 +1327,22 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr } }, [wallets]); - // Refresh the active wallet's balance once whenever the app returns - // to the foreground. Cheap (one NWC round-trip per resume) and - // catches payments that arrived while the app was backgrounded — the - // balance diff then fires the global celebration overlay. We - // deliberately do NOT run a continuous baseline poll: the - // ReceiveSheet's 1.5 s window covers the "actively waiting" case, - // and any other organic refresh (pull-to-refresh, tab swap, invoice - // creation, send flow) will also trigger the detector. Burning a - // network request every 10 s just in case a lightning-address - // payment lands isn't worth the battery for most users. - // NOTE: true background / app-closed delivery needs OS push - // (FCM/APNs + backend relay), tracked in issue #45. + // 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). useEffect(() => { if (!activeWalletId) return; const wallet = wallets.find((w) => w.id === activeWalletId); @@ -1271,10 +1357,34 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr .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(); + if (next === 'active') { + refreshOnce(); + startPoll(); + } else { + stopPoll(); + } }); - return () => sub.remove(); + return () => { + stopPoll(); + sub.remove(); + }; // `wallets` is intentionally omitted so we don't thrash the // subscription on every balance tick — we only re-wire when the // active wallet itself changes. @@ -1316,6 +1426,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr getReceiveAddress, lastIncomingPayment, clearLastIncomingPayment, + expectPayment, isConnected, balance, walletAlias, From 9e6dfdb1766b46cf5e0781cf8be771d49f1bf18c Mon Sep 17 00:00:00 2001 From: Ben Weeks Date: Wed, 22 Apr 2026 00:03:49 +0100 Subject: [PATCH 14/14] =?UTF-8?q?refactor(receive):=20address=20PR=20#135?= =?UTF-8?q?=20review=20=E2=80=94=20Copilot=20+=20self-review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Copilot comments - **SendSheet stale comment** — \`handleOverlayDismiss\` said "success auto-dismisses via the overlay's timer" but the overlay requires an explicit OK tap now. Rewritten to match actual behaviour. - **Context foreground poll ignored connection changes** — the 30 s baseline poll keyed only on \`activeWalletId\`, so a wallet that reconnected (or disconnected) without an id change would leave the poll running against nothing (or not start at all). Now depends on a derived \`activeWalletConnected\` boolean and reads latest state via \`walletsRef\` inside the AppState handler. - **\`onRequestClose\` undefined** — RN Modal warns on Android when \`onRequestClose\` is undefined; extracted a stable handler that swallows the back press only while \`state === 'sending'\`. - **Second payment didn't re-arm overlay** — two payments in quick succession kept \`state === 'success'\` so the confetti transition never fired again. App.tsx now passes \`key={lastIncomingPayment.at}\` to force a re-mount per event. - **expectPayment tick pile-up** — addressed already in the previous commit via an \`inFlight\` ref guard; the Copilot note confirmed the concern was genuine. ## Self-review items 1. **Baseline map growth** — \`paymentBaselinesRef\` is now garbage-collected in the detector effect; entries for walletIds no longer in \`wallets\` are pruned on every run. 2. **Duplicated baseline logic** in ReceiveSheet removed. \`paymentReceived\` (the QR-thumbnail checkmark) now derives from \`lastIncomingPayment\` in context rather than running its own parallel balance-diff with its own \`prevBalance\` / \`needsBaseline\` refs. Single source of truth. 3. **Exact invoice amount over balance delta** — \`expectPayment\` now takes an optional \`expectedAmountSats\`. When \`lookup_invoice\` reports \`paid: true\` we fire \`lastIncomingPayment\` with the known invoice amount (advancing the baseline first so the diff path doesn't double-fire). Fixes the edge case of two invoices settling between polls reporting a combined delta. 5. **Silent bolt11 decode failure** — now \`console.warn\`s in \`__DEV__\` so the fallback-to-balance-poll path is traceable. ## Other - \`IncomingPayment.walletAlias\` dropped — stale if the user renames between detection and dismissal. App overlay only reads \`walletId\` now and can derive alias at render if needed. - \`expectPayment\` JSDoc expanded with the replacement-semantics justification (balance-diff detector catches the displaced invoice). - Added \`paymentHash\` to \`IncomingPayment\` so consumers can tell a known-invoice settle from a generic balance-diff (lightning-address) receive. ## Not addressed here - Issue 6 (SendSheet re-indentation noise) — damage is done on the current commit sequence; file to track hoisting the send overlay state into context (symmetric with receive) so future PRs on this area don't suffer the fragment-wrap reformatting. Co-Authored-By: Claude Opus 4.7 (1M context) --- App.tsx | 5 + src/components/PaymentProgressOverlay.tsx | 12 +- src/components/ReceiveSheet.tsx | 61 ++++---- src/components/SendSheet.tsx | 6 +- src/contexts/WalletContext.tsx | 174 +++++++++++++++++----- 5 files changed, 181 insertions(+), 77 deletions(-) diff --git a/App.tsx b/App.tsx index 5bb64bb2..70aa8aae 100644 --- a/App.tsx +++ b/App.tsx @@ -50,8 +50,13 @@ const toastConfig = { // 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 ( { + if (state === 'sending') return; + onDismiss(); + }; + return ( {/* Particle layer renders BEHIND the card — later siblings stack diff --git a/src/components/ReceiveSheet.tsx b/src/components/ReceiveSheet.tsx index 570e8292..beea2d65 100644 --- a/src/components/ReceiveSheet.tsx +++ b/src/components/ReceiveSheet.tsx @@ -40,7 +40,11 @@ function paymentHashFromBolt11(bolt11: string): string | null { | { value?: string } | undefined; return section?.value ?? null; - } catch { + } 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; } } @@ -98,6 +102,7 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent lightningAddress, getReceiveAddress, expectPayment, + lastIncomingPayment, } = useWallet(); const [capturedWalletId, setCapturedWalletId] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -111,14 +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 prevBalance = useRef(null); - // When true, the next observed balance is treated as the "pre-invoice" - // baseline and is NOT fired as a received payment. Reset on sheet-open, - // wallet-switch, and each new invoice creation — so pending credits - // that settle between app-open and invoice-create get absorbed into - // the baseline instead of firing a bogus celebration attributed to - // the newly-created invoice. - const needsBaseline = useRef(true); const debounceTimer = useRef | null>(null); const bottomSheetRef = useRef(null); const { sendDirectMessage, contacts } = useNostr(); @@ -137,7 +134,6 @@ 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) => { @@ -148,20 +144,19 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent if (!wId) return; const inv = await makeInvoiceForWallet(wId, sats, 'Lightning Piggy'); setInvoice(inv); - // Re-baseline: any pending credit from a previous invoice must - // not fire a celebration attributed to the new one. The first - // balance we see after this poll starts becomes the baseline. - needsBaseline.current = true; // 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. + // 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); + expectPayment(wId, paymentHash, sats); } else { // Unparseable bolt11 — fall back to a single balance refresh. // The WalletContext 30 s baseline poll still picks the @@ -189,8 +184,6 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // 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. - prevBalance.current = null; - needsBaseline.current = true; setOnchainAddress(null); setSatsValue(''); setFiatValue(''); @@ -246,10 +239,6 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent setPaymentReceived(false); setSatsValue(''); setFiatValue(''); - // Wallet switch clears the baseline; the next balance observed for - // the new wallet is treated as the pre-invoice starting point. - prevBalance.current = null; - needsBaseline.current = true; if (selectedWallet?.walletType === 'onchain') { setMode('address'); getReceiveAddress(capturedWalletId) @@ -261,23 +250,23 @@ const ReceiveSheet: React.FC = ({ visible, onClose, presetFriend, onSent // eslint-disable-next-line react-hooks/exhaustive-deps }, [capturedWalletId]); - // The "paymentReceived" checkmark on the QR thumbnail is driven by - // any balance increment while the sheet is open. The actual - // celebration overlay (confetti + tick card) is now rendered at app - // root via WalletContext.lastIncomingPayment — that way it pops on - // any screen, for any wallet, not just while this sheet is visible. + // 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 || balance === null) return; - if (needsBaseline.current) { - prevBalance.current = balance; - needsBaseline.current = false; - return; - } - if (prevBalance.current !== null && balance > prevBalance.current) { - prevBalance.current = balance; + if (!visible) return; + if ( + lastIncomingPayment && + selectedWallet && + lastIncomingPayment.walletId === selectedWallet.id + ) { setPaymentReceived(true); } - }, [balance, visible]); + }, [lastIncomingPayment, selectedWallet, visible]); const scheduleInvoice = (sats: number) => { if (debounceTimer.current) clearTimeout(debounceTimer.current); diff --git a/src/components/SendSheet.tsx b/src/components/SendSheet.tsx index 40c0ff25..929eaea7 100644 --- a/src/components/SendSheet.tsx +++ b/src/components/SendSheet.tsx @@ -548,9 +548,9 @@ const SendSheet: React.FC = ({ }; const handleOverlayDismiss = useCallback(() => { - // Success auto-dismisses via the overlay's timer — on success we also - // close the parent sheet. On error we only dismiss the overlay so the - // user can retry from the filled-in form. + // 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); diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 5d8a6110..4a9d35df 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -21,8 +21,15 @@ import { export interface IncomingPayment { walletId: string; amountSats: number; - walletAlias: string; + // 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'; @@ -126,14 +133,34 @@ interface WalletContextType { lastIncomingPayment: IncomingPayment | null; clearLastIncomingPayment: () => void; - // Kick off aggressive 1 s polling for a specific NWC invoice for the - // next 3 minutes. 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). Subsequent calls - // replace any in-flight expectation. Stops early on detected - // settlement or after the duration elapses. - expectPayment: (walletId: string, paymentHash: string, durationMs?: number) => 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; @@ -1209,8 +1236,12 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr 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(() => { @@ -1222,27 +1253,69 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr }, []); const expectPayment = useCallback( - (walletId: string, paymentHash: string, durationMs: number = 3 * 60 * 1000) => { + ( + 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 [lookupResult] = await Promise.allSettled([ - nwcService.lookupInvoice(walletId, paymentHash), - // The balance refresh is what ultimately drives the - // WalletContext diff-detector and fires the overlay; fire - // both every tick so either path catches the settle. - (async () => { - const b = await nwcService.getBalance(walletId); - if (b !== null) updateWalletInState(walletId, { balance: b }); - })(), - ]); - if (lookupResult.status === 'fulfilled' && lookupResult.value?.paid) { - if (__DEV__) - console.log( - `[Wallet] expected invoice paid (${paymentHash.slice(0, 12)}…) — stopping poll`, - ); - stopExpectedPayment(); + 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; } }; @@ -1259,10 +1332,17 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr } }, durationMs); - expectedPaymentRef.current = { walletId, paymentHash, interval, timeout }; + 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)`, + `[Wallet] expecting payment on ${paymentHash.slice(0, 12)}… (${Math.round(durationMs / 1000)} s window, amount=${expectedAmountSats ?? '?'})`, ); }, [stopExpectedPayment, updateWalletInState], @@ -1299,6 +1379,15 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr // 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; @@ -1317,8 +1406,11 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr setLastIncomingPayment({ walletId: wallet.id, amountSats: delta, - walletAlias: walletLabel(wallet), 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. @@ -1343,12 +1435,24 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr // 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) return; - const wallet = wallets.find((w) => w.id === activeWalletId); - if (!wallet || wallet.walletType === 'onchain' || !wallet.isConnected) return; + 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) => { @@ -1385,11 +1489,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr stopPoll(); sub.remove(); }; - // `wallets` is intentionally omitted so we don't thrash the - // subscription on every balance tick — we only re-wire when the - // active wallet itself changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeWalletId, updateWalletInState]); + }, [activeWalletId, activeWalletConnected, updateWalletInState]); return (