Files
com.blitzwallet/App.tsx
T
Blake KaufmanandGitHub 52dd4e44ec Improve liquid (#890)
* remove unused imports

* remove legacy liquid navigation listener

* add extra details prop to spark receive payment wrapper

* improve robusness of liquid swaps

* bump breez sdk + change swap message

* making sure to only mark failed if payment truly failed

* improve fee
2026-06-04 14:32:39 -04:00

724 lines
24 KiB
TypeScript

/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
*/
import { DefaultTheme, NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React, {
JSX,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { registerRootComponent } from 'expo';
import {
getLocalStorageItem,
retrieveData,
setLocalStorageItem,
removeLocalStorageItem,
} from './app/functions';
import {
AdminLogin,
ConnectingToNodeLoadingScreen,
} from './app/screens/inAccount';
import { GlobalContextProvider } from './context-store/context';
import { WebViewProvider } from './context-store/webViewContext';
import { Linking, Platform, NativeModules } from 'react-native';
import SplashScreen from './app/screens/splashScreen';
import { GlobalContactsList } from './context-store/globalContacts';
import { CreateAccountHome } from './app/screens/createAccount';
import { getLocales } from 'react-native-localize';
import { supportedLanguagesList } from './locales/localeslist';
import { GlobalAppDataProvider } from './context-store/appData';
import { PushNotificationProvider } from './context-store/notificationManager';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import GetThemeColors from './app/hooks/themeColors';
import {
BLITZ_PAYMENT_DEEP_LINK_SCHEMES,
CONTACT_UNIVERSAL_LINK_REGEX,
GIFT_DEEPLINK_REGEX,
POOL_DEEPLINK_REGEX,
PAYLINK_DEEPLINK_REGEX,
LOGIN_SECUITY_MODE_KEY,
LOGIN_SECURITY_MODE_TYPE_KEY,
} from './app/constants';
import { LiquidEventProvider } from './context-store/liquidEventContext';
import { RootstockNavigationListener } from './context-store/SDKNavigation';
import {
GlobalThemeProvider,
useGlobalThemeContext,
} from './context-store/theme';
import { GLobalNodeContextProider } from './context-store/nodeContext';
import { AppStatusProvider, useAppStatus } from './context-store/appStatus';
import { KeysContextProvider, useKeysContext } from './context-store/keys';
import {
FADE_SCREENS,
// FADE_TRANSPARENT_MODAL_SCREENS,
MODAL_CARD_SCREENS,
SLIDE_FROM_BOTTOM_SCREENS,
SLIDE_FROM_RIGHT_SCREENS,
} from './navigation/screens';
import getDeepLinkUser from './app/components/admin/homeComponents/contacts/internalComponents/getDeepLinkUser';
import { navigationRef } from './navigation/navigationService';
import { ImageCacheProvider } from './context-store/imageCache';
import {
runPinAndMnemoicMigration,
runSecureStoreMigrationV2,
} from './app/functions/secureStore';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import HandleLNURLPayments from './context-store/lnurl';
import { SparkWalletProvider } from './context-store/sparkContext';
import { DropdownProvider } from './context-store/dropdownContext';
import * as ExpoSplashScreen from 'expo-splash-screen';
import * as NavigationBar from 'expo-navigation-bar';
import {
setStatusBarBackgroundColor,
setStatusBarStyle,
} from 'expo-status-bar';
import * as SystemUI from 'expo-system-ui';
import { InsetsProvider } from './context-store/insetsProvider';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ToastProvider } from './context-store/toastManager';
import { ToastContainer } from './context-store/toastContainer';
import { RootstockSwapProvider } from './context-store/rootstockSwapContext';
import { SparkConnectionManager } from './context-store/sparkConnection';
import { GlobalNostrWalletConnectProvider } from './context-store/NWC';
import { GlobalServerTimeProvider } from './context-store/serverTime';
import { AuthStatusProvider } from './context-store/authContext';
import { ActiveCustodyAccountProvider } from './context-store/activeAccount';
import { UserBalanceProvider } from './context-store/userBalanceContext';
import { FlashnetProvider } from './context-store/flashnetContext';
import { useTranslation } from 'react-i18next';
import { AnalyticsNumbersProvider } from './context-store/analyticsContext';
import { BTCMapProvider } from './context-store/btcMapContext';
import { SpendAndReplaceProvider } from './context-store/spendAndReplaceContext';
const DeepLinkIntentModule = NativeModules.DeepLinkIntentModule;
const Stack = createNativeStackNavigator();
// will unhide splashscreen when showing dynamic loading in splashscreen component
ExpoSplashScreen.preventAutoHideAsync()
.then(result =>
console.log(`SplashScreen.preventAutoHideAsync() succeeded: ${result}`),
)
.catch(console.warn);
function App(): JSX.Element {
return (
<GestureHandlerRootView>
<SafeAreaProvider>
<InsetsProvider>
<KeyboardProvider>
<ToastProvider>
<GlobalThemeProvider>
<DropdownProvider>
<AppStatusProvider>
<AuthStatusProvider>
<KeysContextProvider>
<BTCMapProvider>
<GlobalContactsList>
<GlobalContextProvider>
<ActiveCustodyAccountProvider>
<WebViewProvider>
<SparkWalletProvider>
<GLobalNodeContextProider>
<GlobalAppDataProvider>
<PushNotificationProvider>
<LiquidEventProvider>
<RootstockSwapProvider>
<GlobalNostrWalletConnectProvider>
<ImageCacheProvider>
<GlobalServerTimeProvider>
<FlashnetProvider>
<UserBalanceProvider>
<AnalyticsNumbersProvider>
<SpendAndReplaceProvider>
{/* <Suspense
fallback={<FullLoadingScreen text={'Loading Page'} />}> */}
<ResetStack />
</SpendAndReplaceProvider>
</AnalyticsNumbersProvider>
</UserBalanceProvider>
</FlashnetProvider>
{/* </Suspense> */}
</GlobalServerTimeProvider>
</ImageCacheProvider>
</GlobalNostrWalletConnectProvider>
</RootstockSwapProvider>
</LiquidEventProvider>
</PushNotificationProvider>
</GlobalAppDataProvider>
</GLobalNodeContextProider>
</SparkWalletProvider>
</WebViewProvider>
</ActiveCustodyAccountProvider>
</GlobalContextProvider>
</GlobalContactsList>
</BTCMapProvider>
</KeysContextProvider>
</AuthStatusProvider>
</AppStatusProvider>
</DropdownProvider>
</GlobalThemeProvider>
</ToastProvider>
</KeyboardProvider>
</InsetsProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
function ResetStack(): JSX.Element | null {
const [initSettings, setInitSettings] = useState<{
isLoggedIn: boolean | null;
hasSecurityEnabled: boolean | null;
isLoaded: boolean | null;
}>({
isLoggedIn: null,
hasSecurityEnabled: null,
isLoaded: null,
});
const [securitySettings, setSecuritySettings] = useState<any>(null);
const [pendingLinkData, setPendingLinkData] = useState<{
url: string;
timestamp: number | null;
}>({
url: '',
timestamp: null,
});
const { theme, darkModeType } = useGlobalThemeContext();
const { didGetToHomepage, appState } = useAppStatus();
const { publicKey, setAccountMnemonic } = useKeysContext();
const didInitializeSettings = useRef(false);
const { backgroundColor } = GetThemeColors();
const { i18n } = useTranslation();
const handleDeepLink = useCallback(
async (event: { url: string }, isInitialLoad = false) => {
console.log(event);
const { url } = event;
try {
if (isInitialLoad) {
const savedDeepLink = await getLocalStorageItem(
'lastHandledDeepLink',
);
const parsedSavedDeeplink = JSON.parse(savedDeepLink) || {
url: '',
dateAdded: null,
};
if (
Platform.OS === 'android' &&
DeepLinkIntentModule &&
DeepLinkIntentModule.clearIntent
) {
DeepLinkIntentModule.clearIntent();
}
if (parsedSavedDeeplink.url === url) {
console.log('Deep link already handled:', url);
return;
}
await setLocalStorageItem(
'lastHandledDeepLink',
JSON.stringify({ url: url, dateAdded: Date.now() }),
);
}
console.log('Deep link URL:', url);
const linkData = {
url: event.url,
timestamp: Date.now(),
};
setPendingLinkData(linkData);
await setLocalStorageItem(
'pendingDeepLinkData',
JSON.stringify(linkData),
);
console.log('Stored pending link in state AND AsyncStorage');
} catch (error) {
console.error('Error handling deep link:', error);
}
},
[setPendingLinkData],
);
const clearDeepLink = useCallback(async () => {
setPendingLinkData({
url: '',
timestamp: null,
});
await removeLocalStorageItem('pendingDeepLinkData');
}, []);
const getInitialURL = useCallback(async () => {
const url = await Linking.getInitialURL();
if (url) {
handleDeepLink({ url }, true);
console.log('Initial deep link stored:', url);
}
}, [handleDeepLink]);
const setNavigationBar = useCallback(async () => {
if (appState === 'active') {
try {
if (Platform.OS === 'android') {
await NavigationBar.setBackgroundColorAsync(backgroundColor);
await NavigationBar.setButtonStyleAsync(theme ? 'light' : 'dark');
setStatusBarBackgroundColor(backgroundColor, false);
setStatusBarStyle(theme ? 'light' : 'dark', false);
}
await SystemUI.setBackgroundColorAsync(backgroundColor);
} catch (error) {
console.warn('Failed to set navigation bar:', error);
}
}
}, [backgroundColor, theme, appState]);
useEffect(() => {
let debounceTimer: NodeJS.Timeout | null = null;
let cancelled = false;
debounceTimer = setTimeout(async () => {
if (cancelled) return;
const { url, timestamp } = pendingLinkData;
if (!url) return;
if (!navigationRef.current) return;
if (appState !== 'active') return;
if (!didGetToHomepage || !publicKey) return;
try {
// Convert URL to lowercase for case-insensitive checks
const lowerUrl = url.toLowerCase();
const contactSchemePrefix = 'blitz-wallet:';
console.log(
'Processing link:',
url,
'at timestamp:',
timestamp,
'conditions:',
{
didGetToHomepage,
hasNavigationRef: !!navigationRef.current,
hasPublicKey: !!publicKey,
},
);
const rootState = navigationRef?.getRootState() ?? { routes: [] };
const blockSoftReset =
(rootState.routes[0]?.name === 'Home' &&
rootState.routes.length === 1) ||
rootState.routes[0]?.name === 'Splash';
if (!blockSoftReset) {
let isContactLink = false;
if (PAYLINK_DEEPLINK_REGEX.test(url)) {
const match = url.match(/paylink\/([A-Za-z0-9]{9})/i);
if (match) {
navigationRef.current.reset({
index: 0,
routes: [
{
name: 'HomeAdmin',
params: { screen: 'Home' },
},
{
name: 'ConfirmPaymentScreen',
params: {
btcAdress: `paylink://${match[1]}`,
fromPage: 'paylink',
},
},
],
});
}
} else if (POOL_DEEPLINK_REGEX.test(url)) {
const poolIdMatch = url.match(/pools\/([0-9a-f-]{36})/i);
if (poolIdMatch) {
navigationRef.current.navigate('PoolsStack', {
screen: 'PoolDetailScreen',
params: { poolId: poolIdMatch[1] },
});
}
} else if (GIFT_DEEPLINK_REGEX.test(url)) {
navigationRef.current.navigate('CustomHalfModal', {
wantedContent: 'ClaimGiftScreen',
url,
sliderHight: 0.6,
claimType: 'claim',
});
} else {
if (CONTACT_UNIVERSAL_LINK_REGEX.test(url)) {
isContactLink = true;
}
if (lowerUrl.startsWith(contactSchemePrefix)) {
// If the URL starts with the contact scheme, check if it contains a wrapped payment scheme.
const contentAfterScheme = lowerUrl.substring(
contactSchemePrefix.length,
);
const isWrappedPaymentLink = BLITZ_PAYMENT_DEEP_LINK_SCHEMES.some(
scheme =>
// Check if the content starts with "scheme:" (e.g., "lightning:")
contentAfterScheme.startsWith(scheme + ':'),
);
isContactLink = !isWrappedPaymentLink;
}
if (isContactLink) {
// Logic for handling contact deep links
const deepLinkContact = await getDeepLinkUser({
deepLinkContent: url,
userProfile: { uuid: publicKey },
});
if (deepLinkContact.didWork) {
navigationRef.current.navigate('ExpandedAddContactsPage', {
newContact: deepLinkContact.data,
});
} else {
navigationRef.current.navigate('ErrorScreen', {
errorMessage: deepLinkContact.reason,
useTranslationString: true,
});
}
} else {
// Regex to strip 'blitz-wallet:' OR 'blitz:' prefix if it exists.
// This ensures only the core payment URI is passed to the ConfirmPaymentScreen.
const paymentUrl = url.replace(/^(blitz-wallet|blitz):/i, '');
navigationRef.current.navigate('ConfirmPaymentScreen', {
btcAdress: paymentUrl,
});
}
}
// Clear the pending link after successful processing
await clearDeepLink();
}
} catch (err) {
console.error('Error processing deep link:', err);
navigationRef.current.navigate('ErrorScreen', {
errorMessage: 'errormessages.processingDeepLinkError',
useTranslationString: true,
});
// Clear the pending link even if there was an error
await clearDeepLink();
}
}, 700);
return () => {
cancelled = true;
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [pendingLinkData, appState, didGetToHomepage, publicKey, clearDeepLink]);
useEffect(() => {
async function loadPendingDeepLink() {
try {
const stored = await getLocalStorageItem('pendingDeepLinkData');
if (stored) {
const parsed = JSON.parse(stored);
console.log('Loaded pending deep link from AsyncStorage:', parsed);
setPendingLinkData(parsed);
}
} catch (error) {
console.error('Error loading pending deep link:', error);
}
}
loadPendingDeepLink();
}, []);
useEffect(() => {
const subscription = Linking.addEventListener('url', handleDeepLink);
return () => {
subscription.remove();
};
}, [handleDeepLink]);
useEffect(() => {
let cancelled = false;
async function initWallet(skipURL = false) {
await runPinAndMnemoicMigration();
await runSecureStoreMigrationV2();
const [
initialURL,
loginModeType,
pin,
mnemonic,
securitySettings,
userSelectedLanguage,
] = await Promise.all([
skipURL ? Promise.resolve() : getInitialURL(),
retrieveData(LOGIN_SECURITY_MODE_TYPE_KEY),
retrieveData('pinHash'),
retrieveData('encryptedMnemonic'),
getLocalStorageItem(LOGIN_SECUITY_MODE_KEY),
getLocalStorageItem('userSelectedLanguage').then(data => {
try {
return JSON.parse(data);
} catch {
return null;
}
}),
]);
const storedSettings = JSON.parse(securitySettings);
const isPinFromMode = loginModeType?.value === 'pin';
const isBiometricFromMode = loginModeType?.value === 'biometric';
const parsedSettings = storedSettings ?? {
isSecurityEnabled: true,
isPinEnabled: isPinFromMode || (!isPinFromMode && !isBiometricFromMode),
isBiometricEnabled: isBiometricFromMode,
};
if (!storedSettings)
setLocalStorageItem(
LOGIN_SECUITY_MODE_KEY,
JSON.stringify(parsedSettings),
);
if (cancelled) return;
if (mnemonic.value && !parsedSettings.isSecurityEnabled) {
setAccountMnemonic(mnemonic.value);
}
setSecuritySettings(parsedSettings);
setInitSettings(prev => {
return {
...prev,
isLoggedIn: !!pin.value && !!mnemonic.value,
hasSecurityEnabled: parsedSettings.isSecurityEnabled,
};
});
let resolvedLanguage = userSelectedLanguage;
if (!resolvedLanguage) {
const [{ languageTag = 'en' }] = getLocales();
const deviceShortId = languageTag.split('-')[0];
const matched = supportedLanguagesList.find(
l => l.shortId === deviceShortId,
);
resolvedLanguage = matched ? matched.id : 'en';
setLocalStorageItem(
'userSelectedLanguage',
JSON.stringify(resolvedLanguage),
);
}
i18n.changeLanguage(resolvedLanguage);
}
if (appState === 'background') return;
if (!didInitializeSettings.current) {
didInitializeSettings.current = true;
initWallet(false);
} else {
didInitializeSettings.current = true;
initWallet(true);
}
return () => {
cancelled = true;
};
}, [appState]);
const handleAnimationFinish = () => {
setInitSettings(prev => {
return { ...prev, isLoaded: true };
});
};
const navigationTheme = useMemo(
() => ({
...DefaultTheme,
dark: theme,
colors: {
...DefaultTheme.colors,
primary: '#1E1E1E',
background: backgroundColor,
card: '#1E1E1E',
text: '#1E1E1E',
border: '#1E1E1E',
notification: '#1E1E1E',
},
}),
[theme, backgroundColor],
);
useEffect(() => {
if (appState === 'background') return;
setNavigationBar();
}, [backgroundColor, theme, appState]);
const screenOptions = useMemo(() => {
return {
headerShown: false,
keyboardHandlingEnabled: true,
};
}, []);
const HomeComponent = useMemo(() => {
if (initSettings.isLoggedIn) {
return initSettings.hasSecurityEnabled
? AdminLogin
: ConnectingToNodeLoadingScreen;
}
return CreateAccountHome;
}, [initSettings.isLoggedIn, initSettings.hasSecurityEnabled]);
if (theme === null || darkModeType === null) {
return null;
}
if (appState === 'background' && !didInitializeSettings.current) return null;
return (
<NavigationContainer theme={navigationTheme} ref={navigationRef}>
{/* <StatusBar style={theme ? 'light' : 'dark'} translucent={true} /> */}
<HandleLNURLPayments />
<RootstockNavigationListener />
<ToastContainer />
<SparkConnectionManager />
{/* <EcashNavigationListener /> */}
{/* <SparkConnectionListener /> */}
{/* <LiquidNavigationListener /> */}
{/* <LightningNavigationListener /> */}
<Stack.Navigator screenOptions={screenOptions}>
<Stack.Screen
name="Splash"
component={SplashScreen}
options={{ animation: 'fade', gestureEnabled: false }}
// initialParams={{ onAnimationFinish: handleAnimationFinish }}
/>
<Stack.Screen
name="SplashReload"
component={SplashScreen}
options={{ animation: 'none', gestureEnabled: false }}
/>
<Stack.Screen
name="Home"
component={HomeComponent}
initialParams={securitySettings}
options={{
animation: 'fade',
gestureEnabled: false,
contentStyle: {
backgroundColor: backgroundColor,
backfaceVisibility: 'hidden',
},
}}
/>
<Stack.Screen
name="ConnectingToNodeLoadingScreen"
component={ConnectingToNodeLoadingScreen}
options={{
gestureEnabled: false,
animation: 'fade',
contentStyle: {
backgroundColor: backgroundColor,
backfaceVisibility: 'hidden',
},
}}
/>
<Stack.Group
screenOptions={{
presentation: 'containedTransparentModal',
animation: 'slide_from_bottom',
}}
>
{SLIDE_FROM_BOTTOM_SCREENS.map(({ name, component: Component }) => (
<Stack.Screen
key={name}
name={name}
component={Component as React.ComponentType<any>}
/>
))}
</Stack.Group>
<Stack.Group
screenOptions={{
animation: 'slide_from_right',
presentation: 'card',
}}
>
{SLIDE_FROM_RIGHT_SCREENS.map(
({ name, component: Component, options = {} }) => (
<Stack.Screen
key={name}
name={name}
component={Component as React.ComponentType<any>}
options={{ ...options }}
/>
),
)}
</Stack.Group>
<Stack.Group
screenOptions={{
animation: 'fade',
presentation: 'containedTransparentModal',
}}
>
{FADE_SCREENS.map(({ name, component: Component, options = {} }) => (
<Stack.Screen
key={name}
name={name}
options={{ ...options }}
component={Component as React.ComponentType<any>}
/>
))}
</Stack.Group>
{/* <Stack.Group
screenOptions={{
animation: 'fade',
presentation: 'transparentModal',
}}
>
{FADE_TRANSPARENT_MODAL_SCREENS.map(
({ name, component: Component }) => (
<Stack.Screen
key={name}
name={name}
component={Component as React.ComponentType<any>}
/>
),
)}
</Stack.Group> */}
<Stack.Group
screenOptions={{
presentation: 'modal',
}}
>
{MODAL_CARD_SCREENS.map(({ name, component: Component }) => (
<Stack.Screen
key={name}
name={name}
component={Component as React.ComponentType<any>}
/>
))}
</Stack.Group>
</Stack.Navigator>
</NavigationContainer>
);
}
export default App;
registerRootComponent(App);