Files

777 lines
28 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 sha256Hash from './app/functions/hash';
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 {
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';
import {
crashlyticsLogReport,
crashlyticsRecordErrorReport,
} from './app/functions/crashlyticsLogs';
const DeepLinkIntentModule = NativeModules.DeepLinkIntentModule;
// Last URL handled via getInitialURL in this JS context. Belt-and-braces only:
// getInitialURL runs once per JS context, and this resets on a JS reload, so
// historical-intent suppression is owned by the native side
// (MainActivity.launchIntentIsHistorical). Deliberately not persisted: a stored
// value would swallow genuine re-taps of reusable links (lnurlp, bitcoin address).
let lastInitialUrl: string | null = null;
// Pending deep links older than this are discarded instead of replayed.
const PENDING_DEEP_LINK_MAX_AGE_MS = 10 * 60 * 1000;
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 [linkTrigger, setLinkTrigger] = useState(0);
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) {
// Suppress Android relaunches from Recents, which redeliver the
// original VIEW intent. A genuine re-tap of the same (possibly
// reusable — lnurlp, bitcoin address) link launches without the
// history flag, so it still goes through. Read before clearIntent.
let launchedFromHistory = false;
if (
Platform.OS === 'android' &&
DeepLinkIntentModule?.isLaunchedFromHistory
) {
launchedFromHistory =
await DeepLinkIntentModule.isLaunchedFromHistory();
}
if (Platform.OS === 'android' && DeepLinkIntentModule?.clearIntent) {
DeepLinkIntentModule.clearIntent();
}
// In-memory guard against duplicate delivery of the launch URL
// within this JS context (clearIntent covers Android natively).
if (launchedFromHistory || lastInitialUrl === url) {
console.log('Deep link already handled:', url);
return;
}
lastInitialUrl = url;
}
console.log(
`[deeplink] handleDeepLink entry url=${url} isInitialLoad=${isInitialLoad}`,
);
console.log('Deep link URL:', url);
const linkData = {
url: event.url,
timestamp: Date.now(),
};
await setLocalStorageItem(
'pendingDeepLinkData',
JSON.stringify(linkData),
);
setLinkTrigger(t => t + 1);
console.log(
`[deeplink] stored pendingDeepLinkData + bumped trigger url=${url}`,
);
} catch (error) {
console.error('Error handling deep link:', error);
}
},
[],
);
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;
if (!navigationRef.current) return;
if (appState !== 'active') return;
if (!didGetToHomepage || !publicKey) return;
const stored = await getLocalStorageItem('pendingDeepLinkData');
if (cancelled || !stored) return;
let parsed: { url?: string; timestamp?: number } | null = null;
try {
parsed = JSON.parse(stored);
} catch {
// Corrupt value would otherwise throw on every run — drop it.
await removeLocalStorageItem('pendingDeepLinkData');
return;
}
const { url, timestamp } = parsed || {};
if (!url) return;
// Discard stale links (e.g. tapped while locked and abandoned) instead
// of replaying a long-expired invoice after a much later unlock.
if (!timestamp || Date.now() - timestamp > PENDING_DEEP_LINK_MAX_AGE_MS) {
console.log(`[deeplink] discarding stale pending link url=${url}`);
await removeLocalStorageItem('pendingDeepLinkData');
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' ||
rootState.routes[0]?.name === 'SplashReload';
console.log(
`[deeplink] processing gate url=${url} didGetToHomepage=${didGetToHomepage} hasPublicKey=${!!publicKey} appState=${appState} navReady=${!!navigationRef.current} blockSoftReset=${blockSoftReset}`,
);
if (blockSoftReset) {
console.log(
`[deeplink] early-return blockSoftReset, keeping stored link url=${url}`,
);
}
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 },
});
// A newer run may have started during the network await; let it
// own the (still stored) link instead of double-processing.
if (cancelled) return;
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,
});
}
}
// Consume the pending link after successful processing
await removeLocalStorageItem('pendingDeepLinkData');
console.log(`[deeplink] consumed pendingDeepLinkData url=${url}`);
}
} catch (err) {
// A cancelled run must not navigate or consume the link a newer run
// may be about to own (e.g. getDeepLinkUser rejecting after cleanup).
if (cancelled) return;
console.error('Error processing deep link:', err);
navigationRef.current.navigate('ErrorScreen', {
errorMessage: 'errormessages.processingDeepLinkError',
useTranslationString: true,
});
// Consume the pending link even if there was an error
await removeLocalStorageItem('pendingDeepLinkData');
console.log(
`[deeplink] consumed pendingDeepLinkData after error url=${url}`,
);
}
}, 700);
return () => {
cancelled = true;
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [linkTrigger, appState, didGetToHomepage, publicKey]);
useEffect(() => {
const subscription = Linking.addEventListener('url', handleDeepLink);
return () => {
subscription.remove();
};
}, [handleDeepLink]);
useEffect(() => {
let cancelled = false;
async function initWallet(skipURL = false) {
crashlyticsLogReport('initWallet: start');
await runPinAndMnemoicMigration();
await runSecureStoreMigrationV2();
crashlyticsLogReport('initWallet: secure store migrations done');
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;
}
}),
]);
crashlyticsLogReport('initWallet: read secure store + local settings');
// A corrupt value here would otherwise throw and strand the app on the
// native splash forever — fall back to the defaults below instead.
let storedSettings = null;
try {
storedSettings = JSON.parse(securitySettings);
} catch {
console.log('Corrupt stored security settings, using defaults');
}
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;
const isNoSecurityLogin =
mnemonic.value && !parsedSettings.isSecurityEnabled;
if (isNoSecurityLogin) {
setAccountMnemonic(mnemonic.value);
}
// For the no-security path the loading screen renders directly as Home, so
// thread the intended seed's hash through its initialParams (kept out of the
// persisted security-settings object) to gate its identity derivation.
setSecuritySettings(
isNoSecurityLogin
? {
...parsedSettings,
expectedMnemonicHash: sha256Hash(mnemonic.value),
}
: parsedSettings,
);
setInitSettings(prev => {
return {
...prev,
isLoggedIn: !!pin.value && !!mnemonic.value,
hasSecurityEnabled: parsedSettings.isSecurityEnabled,
// Settings are now resolved — unblock the render gate below. Until this
// is true the navigator stays unmounted so Home never mounts with the
// wrong (still-loading) component. This is the login race-condition fix.
isLoaded: true,
};
});
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;
// initWallet is the ONLY thing that sets isLoaded, and the render gate below
// returns null until it does. Because preventAutoHideAsync() runs at module
// scope and only SplashScreen ever calls hideAsync(), a rejection here leaves
// the native splash on screen forever with no error and no way out. Always
// open the gate — landing on a screen is recoverable, an endless splash isn't.
const onInitFailure = (err: unknown) => {
console.log('initWallet error', err);
crashlyticsRecordErrorReport(
`initWallet failed: ${(err as Error)?.message}`,
);
setInitSettings(prev => ({ ...prev, isLoaded: true }));
};
if (!didInitializeSettings.current) {
didInitializeSettings.current = true;
initWallet(false).catch(onInitFailure);
} else {
didInitializeSettings.current = true;
initWallet(true).catch(onInitFailure);
}
return () => {
cancelled = true;
};
}, [appState]);
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 || !initSettings.isLoaded) {
return null;
}
if (appState === 'background' && !didInitializeSettings.current) return null;
return (
<NavigationContainer theme={navigationTheme} ref={navigationRef}>
{/* <StatusBar style={theme ? 'light' : 'dark'} translucent={true} /> */}
<HandleLNURLPayments />
<ToastContainer />
<SparkConnectionManager />
<Stack.Navigator screenOptions={screenOptions}>
<Stack.Screen
name="Splash"
component={SplashScreen}
options={{ animation: 'fade', gestureEnabled: false }}
/>
<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);