mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
New AMOLED-style theme with pure-black surfaces, wired into the theme picker, splash screen, and useThemeColor. Reuses the Default launcher icon, so no native/launcher-icon config is required. - colors: add ThemeCode.ONYX, palette.black, and the onyx color block - cards: make the drop shadow theme-aware (cardShadow) and drop it for Onyx, where a glow reads as a grey halo on black; applied in Card and the two Contacts list "fake cards" - separator: darker than the card fill so list rows stay visible on black - active tab icon: Bitcoin brand orange, matching the mint/SAT marks - fix a latent Button bug exposed by Onyx: MintBalanceSelector always passed a LeftAccessory that rendered null, tripping the button's paddingLeft reduction and clipping plain secondary-button labels Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
8.9 KiB
TypeScript
270 lines
8.9 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
import FlashMessage from "react-native-flash-message"
|
|
import {
|
|
SafeAreaProvider,
|
|
} from 'react-native-safe-area-context'
|
|
import {
|
|
setSizeMattersBaseHeight,
|
|
setSizeMattersBaseWidth
|
|
} from '@gocodingnow/rn-size-matters'
|
|
import RNExitApp from 'react-native-exit-app'
|
|
import { changeIcon } from 'react-native-change-icon'
|
|
import { PortalProvider } from '@gorhom/portal'
|
|
import {AppNavigator} from './navigation'
|
|
import {useInitialRootStore, useStores} from './models'
|
|
import {KeyChain, WalletKeys} from './services'
|
|
import {ErrorBoundary} from './screens/ErrorScreen/ErrorBoundary'
|
|
import Config from './config'
|
|
import {log} from './services'
|
|
import { Image, TextStyle, View } from 'react-native'
|
|
import { SystemBars } from 'react-native-edge-to-edge'
|
|
import { ThemeCode, colors, spacing, typography } from './theme'
|
|
import useColorScheme from './theme/useThemeColor'
|
|
import { displayName } from '../app.json'
|
|
import { Text } from './components/Text'
|
|
import useIsInternetReachable from './utils/useIsInternetReachable'
|
|
import { MMKVStorage } from './services'
|
|
import { Button, Screen } from './components'
|
|
import { HotUpdater } from '@hot-updater/react-native'
|
|
import { HOT_UPDATER_URL, HOT_UPDATER_API_KEY } from '@env'
|
|
|
|
setSizeMattersBaseWidth(375)
|
|
setSizeMattersBaseHeight(812)
|
|
|
|
// Configure hot-updater once before any HotUpdater.checkForUpdate() call.
|
|
// As of 0.32.0 the base URL and request headers are set here instead of
|
|
// being passed per-call via the removed getUpdateSource() helper.
|
|
HotUpdater.init({
|
|
baseURL: HOT_UPDATER_URL,
|
|
requestHeaders: {
|
|
Authorization: `Bearer ${HOT_UPDATER_API_KEY}`,
|
|
},
|
|
})
|
|
|
|
|
|
function App() {
|
|
const {
|
|
userSettingsStore,
|
|
relaysStore,
|
|
authStore,
|
|
walletStore,
|
|
walletProfileStore
|
|
} = useStores()
|
|
|
|
const colorScheme = useColorScheme()
|
|
|
|
const [isUserAuthenticated, setIsUserAuthenticated] = useState(false)
|
|
const [isDeviceAuthenticated, setIsDeviceAuthenticated] = useState(false)
|
|
const [isAppLocked, setIsAppLocked] = useState(false)
|
|
const isAuthInProgressRef = useRef(false)
|
|
|
|
const isInternetReachable = useIsInternetReachable() // boolean | null
|
|
const [isNetworkChecked, setIsNetworkChecked] = useState(false)
|
|
|
|
// Track when network check completes
|
|
useEffect(() => {
|
|
if (isInternetReachable !== null) {
|
|
setIsNetworkChecked(true)
|
|
}
|
|
}, [isInternetReachable])
|
|
|
|
const attemptUserAuthentication = useCallback(async () => {
|
|
if (isAuthInProgressRef.current) {
|
|
log.trace('[App] attemptUserAuthentication skipped, auth already in progress')
|
|
return
|
|
}
|
|
|
|
isAuthInProgressRef.current = true
|
|
|
|
try {
|
|
const {isAuthOn} = userSettingsStore
|
|
log.trace('[App] attemptUserAuthentication called', {isAuthOn})
|
|
|
|
const result = await KeyChain.authenticateOnAppStart(isAuthOn)
|
|
log.trace('[App] attemptUserAuthentication result:', { success: result.success, shouldExitApp: result.shouldExitApp })
|
|
|
|
if (result.success) {
|
|
setIsUserAuthenticated(true)
|
|
setIsAppLocked(false)
|
|
return
|
|
}
|
|
|
|
if (result.shouldExitApp) {
|
|
// Fix: show locked UI before exiting so the user isn't stuck on a
|
|
// blank splash if exitApp() has any delay or fails silently.
|
|
setIsAppLocked(true)
|
|
RNExitApp.exitApp()
|
|
return
|
|
}
|
|
|
|
log.trace('[App] attemptUserAuthentication failed, locking app')
|
|
setIsAppLocked(true)
|
|
} catch (e: any) {
|
|
log.error('[App] attemptUserAuthentication caught error', { message: e.message })
|
|
setIsAppLocked(true)
|
|
} finally {
|
|
isAuthInProgressRef.current = false
|
|
}
|
|
}, [userSettingsStore])
|
|
|
|
// === Only safe, always-run startup logic inside useInitialRootStore ===
|
|
const { rehydrated } = useInitialRootStore(() => {
|
|
log.trace('[App]', 'Root store rehydrated')
|
|
|
|
// User authentication (biometrics / PIN)
|
|
if (userSettingsStore.isAuthOn) {
|
|
// Keep UI in locked mode until authentication succeeds.
|
|
attemptUserAuthentication()
|
|
} else {
|
|
setIsUserAuthenticated(true)
|
|
}
|
|
|
|
// Theme sync — apply queued theme on startup and persist to dedicated MMKV key
|
|
const appliedTheme = MMKVStorage.loadTheme()
|
|
if (appliedTheme !== userSettingsStore.nextTheme) {
|
|
MMKVStorage.saveTheme(userSettingsStore.nextTheme)
|
|
}
|
|
|
|
// Icon sync — keeps launcher icon in sync with the applied theme.
|
|
// Handles upgrades from versions before icon switching existed.
|
|
const targetIcon = MMKVStorage.loadTheme() === ThemeCode.GOLDEN ? 'Golden' : 'Default'
|
|
// ios does not work w/o a patch
|
|
try { changeIcon(targetIcon).catch(() => {}) } catch (_) {}
|
|
|
|
// Reset relay statuses
|
|
relaysStore.resetStatuses()
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (
|
|
isInternetReachable === null ||
|
|
isDeviceAuthenticated ||
|
|
!rehydrated
|
|
) {
|
|
log.trace('[App] Not yet ready for device auth check')
|
|
return
|
|
}
|
|
|
|
if (isInternetReachable === true) {
|
|
|
|
if(userSettingsStore.isOnboarded === false) {
|
|
log.trace('[App] User not onboarded → new install, skipping device re-enrollment')
|
|
setIsDeviceAuthenticated(true)
|
|
return
|
|
}
|
|
|
|
if(authStore.isRefreshTokenExpired) {
|
|
log.trace('[App] Network is online and refresh token expired → attempting device re-enrollment')
|
|
|
|
;(async () => {
|
|
try {
|
|
const walletKeys: WalletKeys = await walletStore.getCachedWalletKeys()
|
|
const deviceId = walletProfileStore.device
|
|
|
|
await authStore.clearTokens()
|
|
await authStore.enrollDevice(walletKeys.NOSTR, deviceId)
|
|
|
|
log.trace('[App] Device re-enrollment successful')
|
|
} catch (e: any) {
|
|
log.error('[App] Device re-enrollment failed', { message: e.message })
|
|
} finally {
|
|
// Always unblock the app
|
|
setIsDeviceAuthenticated(true)
|
|
}
|
|
})()
|
|
} else {
|
|
log.trace('[App] Network is online and refresh token valid → device authenticated')
|
|
setIsDeviceAuthenticated(true)
|
|
}
|
|
|
|
} else {
|
|
// Confirmed offline → skip re-enrollment, proceed safely
|
|
log.trace('[App] Network confirmed offline → skipping refresh token check, device authenticated')
|
|
setIsDeviceAuthenticated(true)
|
|
}
|
|
}, [
|
|
isInternetReachable,
|
|
rehydrated
|
|
])
|
|
|
|
// Splash / Locked screen
|
|
if (
|
|
!rehydrated ||
|
|
!isUserAuthenticated ||
|
|
!isDeviceAuthenticated ||
|
|
isAppLocked
|
|
) {
|
|
const splashTheme = MMKVStorage.loadTheme()
|
|
const isLight = splashTheme === ThemeCode.LIGHT ||
|
|
(splashTheme === ThemeCode.DEFAULT && colorScheme === 'light')
|
|
const isGolden = splashTheme === ThemeCode.GOLDEN
|
|
const isOnyx = splashTheme === ThemeCode.ONYX
|
|
const splashBg = isLight
|
|
? colors.palette.neutral200
|
|
: isOnyx
|
|
? colors.palette.black
|
|
: colors.palette.neutral700
|
|
//const splashTextColor = isLight ? colors.palette.neutral800 : colors.palette.neutral100
|
|
|
|
log.trace('[App] Rendering splash/auth screen', {isAppLocked})
|
|
|
|
return (
|
|
<SafeAreaProvider>
|
|
<SystemBars style={isLight ? 'dark' : 'light'} />
|
|
<Screen preset='fixed' backgroundColor={splashBg} safeAreaEdges={['top', 'bottom']}>
|
|
<View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
|
|
<Image source={isGolden
|
|
? require('../android/app/src/main/res/mipmap-xxhdpi/ic_launcher_golden.png')
|
|
: require('../android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png')} />
|
|
</View>
|
|
<Text text={displayName} style={[$title, {paddingBottom: isAppLocked ? spacing.tiny : spacing.extraLarge}]} />
|
|
<View style={{
|
|
paddingHorizontal: spacing.large,
|
|
paddingBottom: spacing.extraLarge,
|
|
}}>
|
|
|
|
{isAppLocked && (
|
|
<>
|
|
<Text size='md' tx='appLocked_message' style={{textAlign: 'center'}} />
|
|
<Button
|
|
preset='default'
|
|
tx='appLocked_authenticate'
|
|
onPress={attemptUserAuthentication}
|
|
style={{marginVertical: spacing.large, alignSelf: 'center'}}
|
|
/>
|
|
<Button
|
|
preset='tertiary'
|
|
tx='appLocked_exit'
|
|
onPress={() => RNExitApp.exitApp()}
|
|
style={{alignSelf: 'center'}}
|
|
/>
|
|
</>
|
|
)}
|
|
</View>
|
|
</Screen>
|
|
</SafeAreaProvider>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<SafeAreaProvider>
|
|
{/* PortalProvider hosts in-tree modals (BottomModal on Android) above the
|
|
navigator + bottom tab bar, avoiding react-native Modal's native window. */}
|
|
<PortalProvider>
|
|
<ErrorBoundary catchErrors={Config.catchErrors}>
|
|
<AppNavigator />
|
|
<FlashMessage position="bottom" />
|
|
</ErrorBoundary>
|
|
</PortalProvider>
|
|
</SafeAreaProvider>
|
|
)
|
|
}
|
|
|
|
const $title: TextStyle = {
|
|
textAlign: "center",
|
|
fontFamily: typography.logo.normal,
|
|
fontSize: spacing.large,
|
|
}
|
|
|
|
export default App
|