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