feat: new onboarding process

This commit is contained in:
Evan Kaloudis
2026-03-30 23:39:10 -04:00
parent f79416a9ab
commit bd8bb9231c
17 changed files with 2596 additions and 156 deletions
+51
View File
@@ -205,6 +205,15 @@ import Order from './views/Order';
import Intro from './views/Intro';
import IntroSplash from './views/IntroSplash';
// Onboarding
import Onboarding1 from './views/Onboarding/Onboarding1';
import RecommendedSettings from './views/Onboarding/RecommendedSettings';
import WalletSetup from './views/Onboarding/WalletSetup';
import WalletType from './views/Onboarding/WalletType';
import NodeChoice from './views/Onboarding/NodeChoice';
import MintDiscovery from './views/Onboarding/MintDiscovery';
import WalletSettings from './views/Onboarding/WalletSettings';
import EditFee from './views/EditFee';
// Embedded LND
@@ -514,6 +523,48 @@ export default class App extends React.PureComponent {
IntroSplash
}
/>
<Stack.Screen
name="Onboarding1" // @ts-ignore:next-line
component={
Onboarding1
}
/>
<Stack.Screen
name="RecommendedSettings" // @ts-ignore:next-line
component={
RecommendedSettings
}
/>
<Stack.Screen
name="WalletSetup" // @ts-ignore:next-line
component={
WalletSetup
}
/>
<Stack.Screen
name="WalletType" // @ts-ignore:next-line
component={
WalletType
}
/>
<Stack.Screen
name="NodeChoice" // @ts-ignore:next-line
component={
NodeChoice
}
/>
<Stack.Screen
name="MintDiscovery" // @ts-ignore:next-line
component={
MintDiscovery
}
/>
<Stack.Screen
name="WalletSettings" // @ts-ignore:next-line
component={
WalletSettings
}
/>
<Stack.Screen
name="Intro" // @ts-ignore:next-line
component={Intro}
Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

+49
View File
@@ -696,6 +696,7 @@
"views.NetworkInfo.maxOutDegree": "Max out degree",
"views.NodeQRScanner.error": "Scanned QR code was not a valid Lightning Node",
"views.Intro.whatIsZeus": "What is Zeus?",
"views.Intro.getStarted": "Get started",
"views.Intro.quickStart": "Quick start",
"views.Intro.quickStartExplainer": "Your own node on the phone",
"views.Intro.lightningOnboarding": "Learn about lightning onboarding",
@@ -713,6 +714,54 @@
"views.Intro.carousel4.title": "Control the lightning",
"views.Intro.carousel4.text": "Go beyond a basic bitcoin wallet and manage your lightning channels, liquidity, and so much more.",
"views.Intro.errorCreatingWallet": "Error creating wallet. Restart the app and try again.",
"views.WalletSetup.howToUseZeus": "How do you want to use ZEUS?",
"views.WalletSetup.createNewWallet": "Create a new wallet",
"views.WalletSetup.connectRemoteNode": "Connect to a remote Lightning node",
"views.WalletType.whatsImportant": "What's more important to you?",
"views.WalletType.control": "Control",
"views.WalletType.lowFees": "Low fees",
"views.WalletType.selfCustody.info1": "Self-custody means you control your own Bitcoin private keys. Your funds are secured by a seed phrase that only you know.",
"views.WalletType.selfCustody.info2": "With self-custody, no third party can freeze, seize, or censor your funds. You have complete financial sovereignty.",
"views.WalletType.selfCustody.info3": "This option requires purchasing a Lightning channel to send and receive payments. There's an upfront cost, but you'll have true ownership of your bitcoin.",
"views.WalletType.cashu.info1": "Cashu is an ecash protocol that provides excellent privacy with lower upfront costs. It's a great way to get started with Lightning.",
"views.WalletType.cashu.info2": "While technically custodial, Cashu mints cannot see your balance or transaction history. Your privacy is protected by cryptographic blinding.",
"views.WalletType.cashu.info3": "ZEUS will prompt you to upgrade to self-custody once you have enough bitcoin to purchase a Lightning channel. It's a stepping stone to full sovereignty.",
"views.Onboarding.setupQuestion": "How do you want to set up ZEUS?",
"views.Onboarding.recommended": "ZEUS' recommended settings",
"views.Onboarding.questionnaire": "Run through the questionnaire",
"views.NodeChoice.whatMatters": "What do you value more in a wallet?",
"views.NodeChoice.speedReliability": "Speed and reliability",
"views.NodeChoice.privacyPower": "Privacy and power user features",
"views.NodeChoice.ldkNode.info1": "LDK Node is a next-generation Lightning implementation that offers fast startup times, reliable payment routing, and a streamlined experience.",
"views.NodeChoice.ldkNode.info2": "It uses Rapid Gossip Sync for near-instant graph updates and cloud backup via VSS (Versioned Storage Service) so your channels are always recoverable.",
"views.NodeChoice.ldkNode.info3": "LDK Node is the recommended choice for most users who want a smooth, reliable Lightning experience on mobile.",
"views.NodeChoice.embeddedLnd.info1": "Embedded LND is a battle-tested Lightning implementation with a rich set of power user features including custom channel management and advanced routing.",
"views.NodeChoice.embeddedLnd.info2": "It connects to the Bitcoin network using Neutrino (BIP 157/158) for enhanced privacy — your node doesn't reveal which addresses or transactions belong to you.",
"views.NodeChoice.embeddedLnd.info3": "Choose this if you prioritize privacy, want full control over your channels, or need advanced features like custom routing policies.",
"views.MintDiscovery.title": "Mint discovery",
"views.MintDiscovery.subtitle": "Choose how to discover Cashu mints",
"views.MintDiscovery.topMints": "Top recommended mints",
"views.MintDiscovery.fetchMints": "Find mints",
"views.MintDiscovery.next": "Next",
"views.MintDiscovery.chooseLater": "Choose later",
"views.MintDiscovery.chooseLater.description": "Skip mint selection for now and choose mints later in settings",
"views.WalletSettings.title": "Settings",
"views.WalletSettings.createWallet": "Create wallet",
"views.RecommendedSettings.title": "Recommended settings",
"views.RecommendedSettings.walletType": "Wallet type",
"views.RecommendedSettings.walletType.cashu": "Cashu ecash (low fees)",
"views.RecommendedSettings.walletType.selfCustody": "Self-custody",
"views.RecommendedSettings.walletType.subtitle": "Start with ecash, upgrade to self-custody later",
"views.RecommendedSettings.nodeImplementation": "Node implementation",
"views.RecommendedSettings.mintSource": "Mint recommendations source",
"views.RecommendedSettings.clipboard": "Clipboard access",
"views.RecommendedSettings.off": "Off",
"views.RecommendedSettings.disabled": "Disabled",
"views.RecommendedSettings.fiat": "Fiat currency conversion rates",
"views.RecommendedSettings.topMints": "Top recommended mints",
"views.RecommendedSettings.loadingMints": "Finding the best mints...",
"views.RecommendedSettings.createWallet": "Create wallet",
"views.Settings.Currency.enableFiat": "Enable fiat currency display",
"views.OpenChannel.openChannel": "Open Channel",
"views.OpenChannel.openChannels": "Open Channels",
"views.OpenChannel.connectPeer": "Connect Peer",
+140
View File
@@ -92,6 +92,14 @@ interface MintRecommendation {
url: string;
}
export interface ScoredMint {
url: string;
count: number;
score: number;
avgRating: number;
reviewCount: number;
}
export interface MintReviewRating {
score: number;
max: number;
@@ -210,6 +218,7 @@ export default class CashuStore {
@observable public mintReviews: Map<string, MintReview[]> = new Map();
@observable public reviewerProfiles: Map<string, ReviewerProfile> =
new Map();
@observable public mintInfoCache: Map<string, any> = new Map();
@observable public loadingTrustedMints = false;
@observable public loadingReviews = false;
@observable public submittingReview = false;
@@ -1318,6 +1327,102 @@ export default class CashuStore {
return { recommendations, reviews: mintReviewsMap };
};
/**
* Calculates a time-weighted score for each mint based on reviews and popularity.
* Returns the top N mints sorted by score descending.
*
* - Reviews with ratings are weighted by recency (exponential decay, ~90 day half-life)
* - Mints with no rated reviews get a neutral score of 0.5
* - Popularity (recommendation count) provides a small boost
* - Recent bad reviews will quickly tank a misbehaving mint's score
*/
public getTopScoredMints = (
count: number = 5,
mode: 'zeus' | 'all' = 'zeus'
): ScoredMint[] => {
const recommendations =
mode === 'all'
? this.mintRecommendations
: this.trustedMintRecommendations;
if (!recommendations || recommendations.length === 0) return [];
const now = Date.now() / 1000;
const scored: ScoredMint[] = recommendations.map((rec) => {
const reviews = this.mintReviews.get(rec.url) || [];
const ratedReviews = reviews.filter((r) => r.rating);
let avgRating = 0.5; // neutral default for unrated mints
if (ratedReviews.length > 0) {
let weightedSum = 0;
let totalWeight = 0;
for (const review of ratedReviews) {
const ageDays = (now - review.createdAt) / 86400;
const weight = Math.exp(-ageDays / 90);
const normalizedRating =
review.rating!.score / review.rating!.max;
weightedSum += normalizedRating * weight;
totalWeight += weight;
}
if (totalWeight > 0) {
avgRating = weightedSum / totalWeight;
}
}
const popularityBoost = Math.log2(1 + rec.count) * 0.05;
const finalScore = avgRating + popularityBoost;
return {
url: rec.url,
count: rec.count,
score: finalScore,
avgRating,
reviewCount: ratedReviews.length
};
});
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, count);
};
/**
* Fetches mint info (name, icon_url, etc.) for a list of mint URLs.
* Results are cached in mintInfoCache. Skips already-cached URLs.
*/
@action
public fetchMintInfoBatch = async (mintUrls: string[]) => {
const uncached = mintUrls.filter((url) => !this.mintInfoCache.has(url));
if (uncached.length === 0) return;
const results = await Promise.allSettled(
uncached.map(async (mintUrl) => {
const info = await Promise.race([
CashuDevKit.fetchMintInfo(mintUrl),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 10000)
)
]);
return { mintUrl, info };
})
);
runInAction(() => {
for (const result of results) {
if (result.status === 'fulfilled') {
this.mintInfoCache.set(
result.value.mintUrl,
result.value.info
);
}
}
});
};
@action
public fetchMints = async () => {
try {
@@ -2528,6 +2633,41 @@ export default class CashuStore {
this.initializing = false;
});
// Auto-add initial mints from onboarding
try {
const settings = this.settingsStore.settings;
const initialMintUrls = settings?.ecash?.initialMintUrls;
if (
initialMintUrls &&
initialMintUrls.length > 0 &&
this.mintUrls.length === 0
) {
console.log(
'Adding initial mints from onboarding:',
initialMintUrls
);
for (const mintUrl of initialMintUrls) {
try {
await this.addMint(mintUrl);
} catch (e) {
console.error(
`Failed to add initial mint ${mintUrl}:`,
e
);
}
}
// Clear initialMintUrls from settings
await this.settingsStore.updateSettings({
ecash: {
...settings.ecash,
initialMintUrls: undefined
}
});
}
} catch (e) {
console.error('Error adding initial mints:', e);
}
// Check status of pending items after initialization (skip when offline)
if (!this.isOffline) {
this.checkPendingItems();
+3 -2
View File
@@ -164,6 +164,7 @@ interface EcashSettings {
enableCashu: boolean;
automaticallySweep: boolean;
sweepThresholdSats?: number;
initialMintUrls?: string[];
}
interface SwapsSettings {
@@ -1450,7 +1451,7 @@ export default class SettingsStore {
privacy: {
defaultBlockExplorer: 'mempool.space',
customBlockExplorer: '',
clipboard: false,
clipboard: true,
lurkerMode: false,
enableMempoolRates: true,
stealthMode: false,
@@ -1516,7 +1517,7 @@ export default class SettingsStore {
isBiometryEnabled: false,
scramblePin: true,
loginBackground: false,
fiatEnabled: false,
fiatEnabled: true,
fiat: DEFAULT_FIAT,
fiatRatesSource: DEFAULT_FIAT_RATES_SOURCE,
// embedded node
+142
View File
@@ -0,0 +1,142 @@
import { v4 as uuidv4 } from 'uuid';
import { optimizeNeutrinoPeers, createLndWallet } from './LndMobileUtils';
import { createLdkNodeWallet } from './LdkNodeUtils';
import { localeString } from './LocaleUtils';
import SettingsStore from '../stores/SettingsStore';
interface WalletCreationParams {
settingsStore: SettingsStore;
enableCashu: boolean;
clipboard: boolean;
fiatEnabled: boolean;
selectedCurrency: string;
fiatRatesSource: string;
initialMintUrls?: string[];
implementation?: string;
onChoosingPeers: () => void;
onCreatingWallet: () => void;
onError: () => void;
onSuccess: () => void;
}
export async function createOnboardingWallet(params: WalletCreationParams) {
const {
settingsStore,
enableCashu,
clipboard,
fiatEnabled,
selectedCurrency,
fiatRatesSource,
initialMintUrls,
implementation,
onChoosingPeers,
onCreatingWallet,
onError,
onSuccess
} = params;
const { setConnectingStatus, updateSettings, settings } = settingsStore;
const commonSettings = {
privacy: {
...settings.privacy,
clipboard
},
fiatEnabled,
fiat: selectedCurrency,
fiatRatesSource,
ecash: {
...settings.ecash,
enableCashu,
...(initialMintUrls && initialMintUrls.length > 0
? { initialMintUrls }
: {})
}
};
if (implementation === 'ldk-node') {
onCreatingWallet();
const nodeDir = uuidv4();
try {
const response = await createLdkNodeWallet({
nodeDir,
network: 'mainnet'
});
if (response && response.mnemonic) {
const nodes = [
{
implementation: 'ldk-node',
embeddedLdkNetwork: 'mainnet',
ldkNodeDir: nodeDir,
ldkMnemonic: response.mnemonic,
nickname: localeString('general.defaultNodeNickname')
}
];
await updateSettings({
nodes,
...commonSettings
});
setConnectingStatus(true);
onSuccess();
} else {
onError();
}
} catch (e) {
onError();
}
} else {
// Default: embedded-lnd
onChoosingPeers();
try {
await optimizeNeutrinoPeers(undefined);
} catch (e) {
onError();
return;
}
onCreatingWallet();
const lndDir = uuidv4();
let response;
try {
response = await createLndWallet({ lndDir });
} catch (e) {
onError();
return;
}
const { wallet, seed, randomBase64 }: any = response;
if (wallet && wallet.admin_macaroon) {
const nodes = [
{
adminMacaroon: wallet.admin_macaroon,
seedPhrase: seed.cipher_seed_mnemonic,
walletPassword: randomBase64,
embeddedLndNetwork: 'Mainnet',
implementation: 'embedded-lnd',
nickname: localeString('general.defaultNodeNickname'),
lndDir
}
];
await updateSettings({
nodes,
...commonSettings
});
setConnectingStatus(true);
onSuccess();
} else {
onError();
}
}
}
+5 -154
View File
@@ -4,28 +4,21 @@ import {
Dimensions,
ImageBackground,
NativeEventSubscription,
View,
Text
View
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { inject, observer } from 'mobx-react';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { v4 as uuidv4 } from 'uuid';
import Globe from '../assets/images/SVG/Globe.svg';
import Wordmark from '../assets/images/SVG/wordmark-black.svg';
import Button from '../components/Button';
import LoadingIndicator from '../components/LoadingIndicator';
import Screen from '../components/Screen';
import { ErrorMessage } from '../components/SuccessErrorMessage';
import SettingsStore, { LOCALE_KEYS } from '../stores/SettingsStore';
import {
optimizeNeutrinoPeers,
createLndWallet
} from '../utils/LndMobileUtils';
import { localeString } from '../utils/LocaleUtils';
import { themeColor } from '../utils/ThemeUtils';
@@ -37,10 +30,7 @@ interface IntroSplashProps {
}
interface IntroSplashState {
choosingPeers: boolean;
creatingWallet: boolean;
error: boolean;
modalBlur: number;
}
@inject('SettingsStore')
@@ -51,10 +41,7 @@ export default class IntroSplash extends React.Component<
> {
private backPressSubscription: NativeEventSubscription;
state = {
choosingPeers: false,
creatingWallet: false,
error: false,
modalBlur: 90
error: false
};
componentDidMount() {
@@ -70,9 +57,6 @@ export default class IntroSplash extends React.Component<
}
handleBackPress = () => {
if (this.state.creatingWallet) {
return true;
}
BackHandler.exitApp();
return true;
};
@@ -83,7 +67,6 @@ export default class IntroSplash extends React.Component<
render() {
const { navigation, SettingsStore } = this.props;
const { creatingWallet, choosingPeers } = this.state;
const { settings } = SettingsStore;
const locale = settings.locale || '';
@@ -93,57 +76,6 @@ export default class IntroSplash extends React.Component<
let localeName;
if (localeItem[0]) localeName = localeItem[0].value;
if (choosingPeers || creatingWallet) {
return (
<Screen>
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
top: 10
}}
>
<View
style={{
width: Dimensions.get('window').width * 0.85,
maxHeight: 200,
marginTop: 10,
alignSelf: 'center'
}}
>
<Wordmark fill={themeColor('highlight')} />
</View>
<Text
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book',
alignSelf: 'center',
fontSize: 15,
padding: 8
}}
>
{choosingPeers
? localeString('views.Intro.choosingPeers')
: localeString(
'views.Intro.creatingWallet'
).replace('Zeus', 'ZEUS')}
</Text>
<View style={{ marginTop: 40 }}>
<LoadingIndicator />
</View>
</View>
<View
style={{
bottom: 56,
position: 'absolute',
alignSelf: 'center'
}}
></View>
</Screen>
);
}
return (
<Screen>
<SafeAreaView
@@ -243,91 +175,10 @@ export default class IntroSplash extends React.Component<
color: themeColor('background')
}}
title={localeString(
'views.Intro.quickStart'
'views.Intro.getStarted'
)}
onPress={async () => {
this.setState({
choosingPeers: true
});
const { SettingsStore } = this.props;
const {
setConnectingStatus,
updateSettings
} = SettingsStore;
try {
await optimizeNeutrinoPeers(
undefined
);
} catch (e) {
this.setState({
error: true,
choosingPeers: false,
creatingWallet: false
});
}
this.setState({
choosingPeers: false,
creatingWallet: true
});
const lndDir = uuidv4();
let response;
try {
response = await createLndWallet({
lndDir
});
} catch (e) {
this.setState({
error: true,
choosingPeers: false,
creatingWallet: false
});
return;
}
const {
wallet,
seed,
randomBase64
}: any = response;
if (wallet && wallet.admin_macaroon) {
let nodes = [
{
adminMacaroon:
wallet.admin_macaroon,
seedPhrase:
seed.cipher_seed_mnemonic,
walletPassword:
randomBase64,
embeddedLndNetwork:
'Mainnet',
implementation:
'embedded-lnd',
nickname: localeString(
'general.defaultNodeNickname'
),
lndDir,
isSqlite: true
}
];
updateSettings({ nodes }).then(
() => {
setConnectingStatus(true);
navigation.navigate(
'Wallet'
);
}
);
} else {
this.setState({
error: true,
creatingWallet: false
});
}
onPress={() => {
navigation.navigate('Onboarding1');
}}
/>
</View>
+518
View File
@@ -0,0 +1,518 @@
import * as React from 'react';
import {
Text as RNText,
View,
ScrollView,
StyleSheet,
TouchableOpacity
} from 'react-native';
import { inject, observer } from 'mobx-react';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Route } from '@react-navigation/native';
import Button from '../../components/Button';
import Header from '../../components/Header';
import LoadingIndicator from '../../components/LoadingIndicator';
import Screen from '../../components/Screen';
import Text from '../../components/Text';
import TextInput from '../../components/TextInput';
import CashuStore, { ScoredMint } from '../../stores/CashuStore';
import SettingsStore from '../../stores/SettingsStore';
import { localeString } from '../../utils/LocaleUtils';
import { themeColor } from '../../utils/ThemeUtils';
import MintAvatar from '../../components/MintAvatar';
type DiscoverMode = 'zeus' | 'all' | 'custom' | 'later';
interface MintDiscoveryProps {
navigation: NativeStackNavigationProp<any, any>;
CashuStore: CashuStore;
SettingsStore: SettingsStore;
route: Route<
'MintDiscovery',
{ enableCashu: boolean; returnTo?: string; implementation?: string }
>;
}
interface MintDiscoveryState {
discoverMode: DiscoverMode;
customNpub: string;
npubError: string;
hasFetched: boolean;
}
@inject('CashuStore', 'SettingsStore')
@observer
export default class MintDiscovery extends React.Component<
MintDiscoveryProps,
MintDiscoveryState
> {
state: MintDiscoveryState = {
discoverMode: 'zeus',
customNpub: '',
npubError: '',
hasFetched: false
};
componentDidMount() {
// Auto-fetch with default mode (ZEUS' contacts)
this.handleFetchMints();
}
validateAndSetNpub = (text: string) => {
const { CashuStore } = this.props;
this.setState({ customNpub: text });
if (text.trim() === '') {
this.setState({ npubError: '' });
return;
}
const isValid = CashuStore.validateNpub(text);
if (!isValid) {
this.setState({
npubError: localeString('views.Cashu.AddMint.invalidNpub')
});
} else {
this.setState({ npubError: '' });
}
};
handleDiscoverSelect = (mode: DiscoverMode) => {
this.setState({ discoverMode: mode }, () => {
if (mode !== 'custom' && mode !== 'later') {
this.handleFetchMints();
}
});
};
handleFetchMints = () => {
const { CashuStore } = this.props;
const { discoverMode, customNpub, npubError } = this.state;
if (discoverMode === 'custom') {
if (npubError || !customNpub.trim()) {
return;
}
}
this.setState({ hasFetched: true });
if (discoverMode === 'all') {
CashuStore.fetchMints();
} else if (discoverMode === 'zeus') {
CashuStore.fetchMintsFromFollows();
} else if (discoverMode === 'custom') {
CashuStore.fetchMintsFromFollows(customNpub);
}
};
isFetchDisabled = () => {
const { discoverMode, customNpub, npubError } = this.state;
if (discoverMode === 'custom') {
return !customNpub.trim() || !!npubError;
}
return false;
};
handleNext = () => {
const { navigation, route, CashuStore } = this.props;
const { discoverMode } = this.state;
const returnTo = route.params?.returnTo;
const enableCashu = route.params?.enableCashu;
const implementation = route.params?.implementation;
let initialMintUrls: string[] = [];
if (discoverMode !== 'later') {
const topMints = CashuStore.getTopScoredMints(
5,
discoverMode === 'all' ? 'all' : 'zeus'
);
initialMintUrls = topMints.map((m: ScoredMint) => m.url);
}
if (returnTo) {
navigation.navigate(returnTo, {
discoverMode,
initialMintUrls
});
} else {
navigation.navigate('WalletSettings', {
enableCashu,
initialMintUrls,
implementation
});
}
};
renderRadioOption = (
mode: DiscoverMode,
titleKey: string,
descriptionKey: string
) => {
const { discoverMode } = this.state;
const highlightColor = themeColor('highlight');
const isSelected = discoverMode === mode;
return (
<TouchableOpacity
style={[
styles.optionItem,
{ backgroundColor: themeColor('background') },
isSelected && [
styles.optionItemSelected,
{ borderColor: highlightColor }
]
]}
onPress={() => this.handleDiscoverSelect(mode)}
>
<View
style={[styles.radioOuter, { borderColor: highlightColor }]}
>
{isSelected && (
<View
style={[
styles.radioInner,
{ backgroundColor: highlightColor }
]}
/>
)}
</View>
<View style={styles.optionTextContainer}>
<Text
style={{
...styles.optionTitle,
color: themeColor('text')
}}
>
{localeString(titleKey)}
</Text>
<Text
style={{
...styles.optionDescription,
color: themeColor('secondaryText')
}}
>
{localeString(descriptionKey)}
</Text>
</View>
</TouchableOpacity>
);
};
render() {
const { navigation, CashuStore } = this.props;
const { discoverMode, customNpub, npubError, hasFetched } = this.state;
const isLater = discoverMode === 'later';
const isLoading = isLater
? false
: discoverMode === 'all'
? CashuStore.loading
: CashuStore.loadingTrustedMints;
const topMints =
hasFetched && !isLater
? CashuStore.getTopScoredMints(
5,
discoverMode === 'all' ? 'all' : 'zeus'
)
: [];
// Fetch mint info for icons when top mints are available
if (topMints.length > 0 && !isLoading) {
const urls = topMints.map((m: ScoredMint) => m.url);
const hasUncached = urls.some(
(u: string) => !CashuStore.mintInfoCache.has(u)
);
if (hasUncached) {
CashuStore.fetchMintInfoBatch(urls);
}
}
return (
<Screen>
<Header
leftComponent="Back"
centerComponent={{
text: localeString('views.MintDiscovery.title'),
style: {
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}
}}
navigation={navigation}
/>
<ScrollView
style={{ flex: 1, paddingHorizontal: 15, marginTop: 5 }}
keyboardShouldPersistTaps="handled"
>
<Text
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book',
fontSize: 14,
textAlign: 'center',
marginBottom: 20,
marginTop: 10
}}
>
{localeString('views.MintDiscovery.subtitle')}
</Text>
{this.renderRadioOption(
'zeus',
'views.Cashu.AddMint.zeusFollows',
'views.Cashu.AddMint.zeusFollows.description'
)}
{this.renderRadioOption(
'all',
'views.Cashu.AddMint.allNostrUsers',
'views.Cashu.AddMint.allNostrUsers.description'
)}
{this.renderRadioOption(
'custom',
'views.Cashu.AddMint.yourFollows',
'views.Cashu.AddMint.yourFollows.description'
)}
{this.renderRadioOption(
'later',
'views.MintDiscovery.chooseLater',
'views.MintDiscovery.chooseLater.description'
)}
{discoverMode === 'custom' && (
<View style={styles.npubInputContainer}>
<Text
style={{
...styles.npubLabel,
color: themeColor('secondaryText')
}}
>
{localeString('views.Cashu.AddMint.enterNpub')}
</Text>
<TextInput
placeholder="npub1..."
value={customNpub}
onChangeText={this.validateAndSetNpub}
autoCapitalize="none"
autoCorrect={false}
error={!!npubError}
textColor={
npubError
? themeColor('warning')
: undefined
}
/>
{npubError ? (
<Text
style={{
...styles.npubError,
color: themeColor('warning')
}}
>
{npubError}
</Text>
) : null}
<View style={{ marginTop: 10 }}>
<Button
title={localeString(
'views.MintDiscovery.fetchMints'
)}
onPress={this.handleFetchMints}
disabled={this.isFetchDisabled()}
/>
</View>
</View>
)}
{/* Top mints section */}
{hasFetched && discoverMode !== 'later' && (
<View style={styles.mintsSection}>
<Text
style={{
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Medium',
fontSize: 16,
marginBottom: 10
}}
>
{localeString('views.MintDiscovery.topMints')}
</Text>
{isLoading ? (
<View style={{ padding: 20 }}>
<LoadingIndicator />
</View>
) : topMints.length > 0 ? (
topMints.map((mint: ScoredMint) => {
const mintInfo =
CashuStore.mintInfoCache.get(mint.url);
const iconUrl = mintInfo?.icon_url;
const mintName = mintInfo?.name;
return (
<View
key={mint.url}
style={[
styles.mintItem,
{
backgroundColor:
themeColor('secondary'),
flexDirection: 'row',
alignItems: 'center'
}
]}
>
<MintAvatar
iconUrl={iconUrl}
name={mintName || mint.url}
/>
<View
style={{
flex: 1,
marginLeft: 12
}}
>
{mintName && (
<RNText
style={{
color: themeColor(
'text'
),
fontFamily:
'PPNeueMontreal-Medium',
fontSize: 14,
marginBottom: 2
}}
numberOfLines={1}
>
{mintName}
</RNText>
)}
<RNText
style={{
color: themeColor(
'secondaryText'
),
fontFamily:
'PPNeueMontreal-Book',
fontSize: 12
}}
numberOfLines={1}
>
{mint.url.replace(
'https://',
''
)}
</RNText>
</View>
</View>
);
})
) : (
<Text
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book',
fontSize: 14,
textAlign: 'center',
padding: 20
}}
>
{CashuStore.error
? CashuStore.error_msg
: localeString(
'views.RecommendedSettings.loadingMints'
)}
</Text>
)}
</View>
)}
</ScrollView>
<View
style={{
padding: 20,
paddingBottom: 40
}}
>
<Button
title={localeString('views.MintDiscovery.next')}
onPress={this.handleNext}
disabled={isLoading && discoverMode !== 'later'}
/>
</View>
</Screen>
);
}
}
const styles = StyleSheet.create({
optionItem: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 15,
borderRadius: 10,
marginBottom: 10
},
optionItemSelected: {
borderWidth: 2
},
radioOuter: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
marginTop: 2
},
radioInner: {
width: 12,
height: 12,
borderRadius: 6
},
optionTextContainer: {
flex: 1
},
optionTitle: {
fontFamily: 'PPNeueMontreal-Medium',
fontSize: 16,
marginBottom: 4
},
optionDescription: {
fontFamily: 'PPNeueMontreal-Book',
fontSize: 13
},
npubInputContainer: {
marginTop: 10,
paddingHorizontal: 5
},
npubLabel: {
fontFamily: 'PPNeueMontreal-Book',
fontSize: 14,
marginBottom: 8
},
npubError: {
fontFamily: 'PPNeueMontreal-Book',
fontSize: 12,
marginTop: 5
},
mintsSection: {
marginTop: 25,
borderTopWidth: 1,
borderTopColor: '#333',
paddingTop: 15
},
mintItem: {
padding: 12,
borderRadius: 8,
marginBottom: 8
}
});
+206
View File
@@ -0,0 +1,206 @@
import * as React from 'react';
import {
ImageBackground,
View,
StyleSheet,
TouchableOpacity
} from 'react-native';
import { inject, observer } from 'mobx-react';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Route } from '@react-navigation/native';
import Button from '../../components/Button';
import Header from '../../components/Header';
import Screen from '../../components/Screen';
import Text from '../../components/Text';
import ModalStore from '../../stores/ModalStore';
import { font } from '../../utils/FontUtils';
import { localeString } from '../../utils/LocaleUtils';
import { themeColor } from '../../utils/ThemeUtils';
interface NodeChoiceProps {
navigation: NativeStackNavigationProp<any, any>;
ModalStore: ModalStore;
route: Route<'NodeChoice', { enableCashu: boolean; returnTo?: string }>;
}
@inject('ModalStore')
@observer
export default class NodeChoice extends React.Component<NodeChoiceProps, {}> {
showLdkNodeInfo = () => {
const { ModalStore } = this.props;
ModalStore!.toggleInfoModal({
text: [
localeString('views.NodeChoice.ldkNode.info1'),
localeString('views.NodeChoice.ldkNode.info2'),
localeString('views.NodeChoice.ldkNode.info3')
]
});
};
showEmbeddedLndInfo = () => {
const { ModalStore } = this.props;
ModalStore!.toggleInfoModal({
text: [
localeString('views.NodeChoice.embeddedLnd.info1'),
localeString('views.NodeChoice.embeddedLnd.info2'),
localeString('views.NodeChoice.embeddedLnd.info3')
]
});
};
handleSelect = (implementation: 'ldk-node' | 'embedded-lnd') => {
const { navigation, route } = this.props;
const enableCashu = route.params?.enableCashu ?? false;
const returnTo = route.params?.returnTo;
if (returnTo) {
navigation.navigate(returnTo, { enableCashu, implementation });
} else {
navigation.navigate('RecommendedSettings', {
enableCashu,
implementation
});
}
};
render() {
const { navigation } = this.props;
return (
<Screen>
<ImageBackground
source={require('../../assets/images/onboarding/eagle.jpeg')}
resizeMode="cover"
style={styles.backgroundImage}
>
<View style={styles.container}>
<Header leftComponent="Back" navigation={navigation} />
<View style={styles.contentContainer}>
<View style={styles.headerContainer}>
<Text
style={{
fontSize: 32,
fontFamily: font('marlideBold'),
color: themeColor('text'),
textAlign: 'center'
}}
>
{localeString(
'views.NodeChoice.whatMatters'
)}
</Text>
</View>
<View style={styles.buttonsContainer}>
<View style={styles.optionContainer}>
<View style={styles.buttonWithInfo}>
<View style={styles.buttonFlex}>
<Button
title={localeString(
'views.NodeChoice.speedReliability'
)}
onPress={() =>
this.handleSelect(
'ldk-node'
)
}
/>
</View>
<TouchableOpacity
onPress={this.showLdkNodeInfo}
style={styles.infoButton}
>
<Text
style={{
fontSize: 20,
color: themeColor('text'),
fontWeight: 'bold'
}}
>
{' ?'}
</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.optionContainer}>
<View style={styles.buttonWithInfo}>
<View style={styles.buttonFlex}>
<Button
title={localeString(
'views.NodeChoice.privacyPower'
)}
onPress={() =>
this.handleSelect(
'embedded-lnd'
)
}
secondary
/>
</View>
<TouchableOpacity
onPress={this.showEmbeddedLndInfo}
style={styles.infoButton}
>
<Text
style={{
fontSize: 20,
color: themeColor('text'),
fontWeight: 'bold'
}}
>
{' ?'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
</View>
</ImageBackground>
</Screen>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1
},
backgroundImage: {
flex: 1,
justifyContent: 'center'
},
contentContainer: {
flex: 1,
justifyContent: 'space-between',
paddingVertical: 40
},
headerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20
},
buttonsContainer: {
paddingHorizontal: 10,
paddingBottom: 20
},
optionContainer: {
padding: 10
},
buttonWithInfo: {
flexDirection: 'row',
alignItems: 'center'
},
buttonFlex: {
flex: 1
},
infoButton: {
padding: 10,
marginLeft: 5
}
});
+108
View File
@@ -0,0 +1,108 @@
import * as React from 'react';
import { ImageBackground, View, StyleSheet } from 'react-native';
import { observer } from 'mobx-react';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Button from '../../components/Button';
import Header from '../../components/Header';
import Screen from '../../components/Screen';
import Text from '../../components/Text';
import { font } from '../../utils/FontUtils';
import { localeString } from '../../utils/LocaleUtils';
import { themeColor } from '../../utils/ThemeUtils';
interface Onboarding1Props {
navigation: NativeStackNavigationProp<any, any>;
}
@observer
export default class Onboarding1 extends React.Component<Onboarding1Props, {}> {
render() {
const { navigation } = this.props;
return (
<Screen>
<ImageBackground
source={require('../../assets/images/onboarding/zeus.jpeg')}
resizeMode="cover"
style={styles.backgroundImage}
>
<View style={styles.container}>
<Header leftComponent="Back" navigation={navigation} />
<View style={styles.contentContainer}>
<View style={styles.headerContainer}>
<Text
style={{
fontSize: 32,
fontFamily: font('marlideBold'),
color: themeColor('text'),
textAlign: 'center'
}}
>
{localeString(
'views.Onboarding.setupQuestion'
)}
</Text>
</View>
<View style={styles.buttonsContainer}>
<View style={styles.buttonWrapper}>
<Button
title={localeString(
'views.Onboarding.recommended'
)}
onPress={() =>
navigation.navigate(
'RecommendedSettings'
)
}
/>
</View>
<View style={styles.buttonWrapper}>
<Button
title={localeString(
'views.Onboarding.questionnaire'
)}
onPress={() =>
navigation.navigate('WalletSetup')
}
secondary
/>
</View>
</View>
</View>
</View>
</ImageBackground>
</Screen>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1
},
backgroundImage: {
flex: 1,
justifyContent: 'center'
},
contentContainer: {
flex: 1,
justifyContent: 'space-between',
paddingVertical: 40
},
headerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20
},
buttonsContainer: {
paddingHorizontal: 10,
paddingBottom: 20
},
buttonWrapper: {
padding: 10
}
});
+635
View File
@@ -0,0 +1,635 @@
import * as React from 'react';
import { View, ScrollView, Dimensions } from 'react-native';
import { inject, observer } from 'mobx-react';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Route } from '@react-navigation/native';
import { Icon, ListItem } from '@rneui/themed';
import Button from '../../components/Button';
import Header from '../../components/Header';
import LoadingIndicator from '../../components/LoadingIndicator';
import Screen from '../../components/Screen';
import Text from '../../components/Text';
import CashuStore, { ScoredMint } from '../../stores/CashuStore';
import SettingsStore, {
CURRENCY_KEYS,
DEFAULT_FIAT,
DEFAULT_FIAT_RATES_SOURCE
} from '../../stores/SettingsStore';
import { createOnboardingWallet } from '../../utils/WalletCreationUtils';
import { localeString } from '../../utils/LocaleUtils';
import { themeColor } from '../../utils/ThemeUtils';
import MintAvatar from '../../components/MintAvatar';
import Wordmark from '../../assets/images/SVG/wordmark-black.svg';
type DiscoverMode = 'zeus' | 'all' | 'custom' | 'later';
interface RecommendedSettingsProps {
navigation: NativeStackNavigationProp<any, any>;
SettingsStore: SettingsStore;
CashuStore: CashuStore;
route: Route<
'RecommendedSettings',
{
enableCashu?: boolean;
discoverMode?: DiscoverMode;
initialMintUrls?: string[];
clipboard?: boolean;
fiatEnabled?: boolean;
selectedCurrency?: string;
fiatRatesSource?: string;
implementation?: string;
}
>;
}
interface RecommendedSettingsState {
enableCashu: boolean;
discoverMode: DiscoverMode;
clipboard: boolean;
fiatEnabled: boolean;
selectedCurrency: string;
fiatRatesSource: string;
initialMintUrls: string[];
implementation: string;
choosingPeers: boolean;
creatingWallet: boolean;
error: boolean;
}
@inject('SettingsStore', 'CashuStore')
@observer
export default class RecommendedSettings extends React.Component<
RecommendedSettingsProps,
RecommendedSettingsState
> {
state: RecommendedSettingsState = {
enableCashu: true,
discoverMode: 'zeus',
clipboard: true,
fiatEnabled: true,
selectedCurrency: DEFAULT_FIAT,
fiatRatesSource: DEFAULT_FIAT_RATES_SOURCE,
initialMintUrls: [],
implementation: 'ldk-node',
choosingPeers: false,
creatingWallet: false,
error: false
};
componentDidMount() {
const { navigation, CashuStore, SettingsStore } = this.props;
// Fetch mints with default mode (ZEUS' contacts)
CashuStore.fetchMintsFromFollows();
// Listen for updates when returning from questionnaire steps
navigation.addListener('focus', async () => {
this.syncFromRouteParams();
// Sync settings saved to the store by sub-screens
const settings = await SettingsStore.getSettings();
this.setState({
clipboard: settings.privacy?.clipboard ?? true,
fiatEnabled: settings.fiatEnabled ?? true,
selectedCurrency: settings.fiat || DEFAULT_FIAT,
fiatRatesSource:
settings.fiatRatesSource || DEFAULT_FIAT_RATES_SOURCE
});
});
}
syncFromRouteParams = () => {
const { route } = this.props;
const params = route.params;
if (!params) return;
if (params.enableCashu !== undefined) {
this.setState({ enableCashu: params.enableCashu });
}
if (params.discoverMode) {
this.setState({ discoverMode: params.discoverMode });
}
if (params.initialMintUrls) {
this.setState({ initialMintUrls: params.initialMintUrls });
}
if (params.clipboard !== undefined) {
this.setState({ clipboard: params.clipboard });
}
if (params.fiatEnabled !== undefined) {
this.setState({ fiatEnabled: params.fiatEnabled });
}
if (params.selectedCurrency) {
this.setState({ selectedCurrency: params.selectedCurrency });
}
if (params.fiatRatesSource) {
this.setState({ fiatRatesSource: params.fiatRatesSource });
}
if (params.implementation) {
this.setState({ implementation: params.implementation });
}
};
handleCreateWallet = async () => {
const { navigation, SettingsStore, CashuStore } = this.props;
const {
enableCashu,
discoverMode,
clipboard,
fiatEnabled,
selectedCurrency,
fiatRatesSource,
initialMintUrls,
implementation
} = this.state;
// Use stored mint URLs or compute from top scored mints
let mintUrls = initialMintUrls;
if (enableCashu && mintUrls.length === 0) {
const topMints = CashuStore.getTopScoredMints(
5,
discoverMode === 'all' ? 'all' : 'zeus'
);
mintUrls = topMints.map((m: ScoredMint) => m.url);
}
await createOnboardingWallet({
settingsStore: SettingsStore,
enableCashu,
clipboard,
fiatEnabled,
selectedCurrency,
fiatRatesSource,
initialMintUrls: enableCashu ? mintUrls : undefined,
implementation,
onChoosingPeers: () =>
this.setState({ choosingPeers: true, error: false }),
onCreatingWallet: () =>
this.setState({
choosingPeers: false,
creatingWallet: true
}),
onError: () =>
this.setState({
error: true,
choosingPeers: false,
creatingWallet: false
}),
onSuccess: () => navigation.navigate('Wallet')
});
};
getMintSourceLabel = () => {
const { discoverMode } = this.state;
switch (discoverMode) {
case 'zeus':
return localeString('views.Cashu.AddMint.zeusFollows');
case 'all':
return localeString('views.Cashu.AddMint.allNostrUsers');
case 'custom':
return localeString('views.Cashu.AddMint.yourFollows');
case 'later':
return localeString('views.MintDiscovery.chooseLater');
}
};
render() {
const { navigation, CashuStore } = this.props;
const {
enableCashu,
discoverMode,
clipboard,
fiatEnabled,
selectedCurrency,
implementation,
choosingPeers,
creatingWallet,
error
} = this.state;
const currencyInfo = CURRENCY_KEYS.find(
(c) => c.value === selectedCurrency
);
const currencyFlag = currencyInfo?.flag || '';
const currencyLabel = currencyFlag
? `${currencyFlag} ${selectedCurrency}`
: selectedCurrency;
if (choosingPeers || creatingWallet) {
return (
<Screen>
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
top: 10
}}
>
<View
style={{
width: Dimensions.get('window').width * 0.85,
maxHeight: 200,
marginTop: 10,
alignSelf: 'center'
}}
>
<Wordmark fill={themeColor('highlight')} />
</View>
<Text
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book',
alignSelf: 'center',
fontSize: 15,
padding: 8
}}
>
{choosingPeers
? localeString('views.Intro.choosingPeers')
: localeString(
'views.Intro.creatingWallet'
).replace('Zeus', 'ZEUS')}
</Text>
<View style={{ marginTop: 40 }}>
<LoadingIndicator />
</View>
</View>
</Screen>
);
}
const isLater = discoverMode === 'later';
const isLoadingMints = isLater
? false
: discoverMode === 'all'
? CashuStore.loading
: CashuStore.loadingTrustedMints;
const topMints =
enableCashu && !isLater
? CashuStore.getTopScoredMints(
5,
discoverMode === 'all' ? 'all' : 'zeus'
)
: [];
// Fetch mint info for icons when top mints are available
if (topMints.length > 0 && !isLoadingMints) {
const urls = topMints.map((m: ScoredMint) => m.url);
const hasUncached = urls.some(
(u: string) => !CashuStore.mintInfoCache.has(u)
);
if (hasUncached) {
CashuStore.fetchMintInfoBatch(urls);
}
}
return (
<Screen>
<Header
leftComponent="Back"
centerComponent={{
text: localeString('views.RecommendedSettings.title'),
style: {
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}
}}
navigation={navigation}
/>
<ScrollView
style={{ flex: 1, paddingHorizontal: 15, marginTop: 5 }}
keyboardShouldPersistTaps="handled"
>
{error && (
<View
style={{
backgroundColor: themeColor('error'),
padding: 15,
borderRadius: 5,
marginBottom: 15
}}
>
<Text
style={{
color: themeColor('text'),
textAlign: 'center'
}}
>
{localeString(
'views.Intro.errorCreatingWallet'
)}
</Text>
</View>
)}
{/* Wallet type */}
<ListItem
containerStyle={{
backgroundColor: 'transparent',
paddingHorizontal: 0,
marginTop: 10
}}
onPress={() =>
navigation.navigate('WalletType', {
returnTo: 'RecommendedSettings'
})
}
>
<ListItem.Content>
<ListItem.Title
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString(
'views.RecommendedSettings.walletType'
)}
</ListItem.Title>
<ListItem.Subtitle
style={{
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Medium',
marginTop: 4
}}
>
{enableCashu
? localeString(
'views.RecommendedSettings.walletType.cashu'
)
: localeString(
'views.RecommendedSettings.walletType.selfCustody'
)}
</ListItem.Subtitle>
{enableCashu && (
<Text
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book',
fontSize: 12,
marginTop: 2
}}
>
{localeString(
'views.RecommendedSettings.walletType.subtitle'
)}
</Text>
)}
</ListItem.Content>
<Icon
name="keyboard-arrow-right"
color={themeColor('secondaryText')}
/>
</ListItem>
{/* Node implementation */}
<ListItem
containerStyle={{
backgroundColor: 'transparent',
paddingHorizontal: 0,
borderTopWidth: 1,
borderTopColor: themeColor('separator')
}}
onPress={() =>
navigation.navigate('NodeChoice', {
returnTo: 'RecommendedSettings',
enableCashu
})
}
>
<ListItem.Content>
<ListItem.Title
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString(
'views.RecommendedSettings.nodeImplementation'
)}
</ListItem.Title>
<ListItem.Subtitle
style={{
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Medium',
marginTop: 4
}}
>
{implementation === 'ldk-node'
? `LDK Node (${localeString(
'views.NodeChoice.speedReliability'
).toLowerCase()})`
: `Embedded LND (${localeString(
'views.NodeChoice.privacyPower'
).toLowerCase()})`}
</ListItem.Subtitle>
</ListItem.Content>
<Icon
name="keyboard-arrow-right"
color={themeColor('secondaryText')}
/>
</ListItem>
{/* Mint source (only when ecash enabled) */}
{enableCashu && (
<ListItem
containerStyle={{
backgroundColor: 'transparent',
paddingHorizontal: 0,
borderTopWidth: 1,
borderTopColor: themeColor('separator')
}}
onPress={() =>
navigation.navigate('MintDiscovery', {
returnTo: 'RecommendedSettings',
enableCashu: true
})
}
>
<ListItem.Content>
<ListItem.Title
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString(
'views.RecommendedSettings.mintSource'
)}
</ListItem.Title>
<ListItem.Subtitle
style={{
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Medium',
marginTop: 4
}}
>
{this.getMintSourceLabel()}
</ListItem.Subtitle>
{isLoadingMints ? (
<View
style={{
marginTop: 10,
height: 36,
justifyContent: 'center'
}}
>
<LoadingIndicator size={24} />
</View>
) : topMints.length > 0 ? (
<View
style={{
flexDirection: 'row',
marginTop: 10,
gap: 8
}}
>
{topMints.map((mint: ScoredMint) => {
const mintInfo =
CashuStore.mintInfoCache.get(
mint.url
);
return (
<MintAvatar
key={mint.url}
iconUrl={mintInfo?.icon_url}
name={
mintInfo?.name ||
mint.url
}
size="medium"
style={{
width: 36,
height: 36,
borderRadius: 18
}}
/>
);
})}
</View>
) : null}
</ListItem.Content>
<Icon
name="keyboard-arrow-right"
color={themeColor('secondaryText')}
/>
</ListItem>
)}
{/* Clipboard */}
<ListItem
containerStyle={{
backgroundColor: 'transparent',
paddingHorizontal: 0,
borderTopWidth: 1,
borderTopColor: themeColor('separator')
}}
onPress={() =>
navigation.navigate('WalletSettings', {
returnTo: 'RecommendedSettings',
enableCashu
})
}
>
<ListItem.Content>
<ListItem.Title
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString(
'views.RecommendedSettings.clipboard'
)}
</ListItem.Title>
<ListItem.Subtitle
style={{
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Medium',
marginTop: 4
}}
>
{clipboard
? localeString(
'views.Settings.Privacy.clipboard'
)
: localeString(
'views.RecommendedSettings.off'
)}
</ListItem.Subtitle>
</ListItem.Content>
<Icon
name="keyboard-arrow-right"
color={themeColor('secondaryText')}
/>
</ListItem>
{/* Fiat */}
<ListItem
containerStyle={{
backgroundColor: 'transparent',
paddingHorizontal: 0,
borderTopWidth: 1,
borderTopColor: themeColor('separator')
}}
onPress={() =>
navigation.navigate('WalletSettings', {
returnTo: 'RecommendedSettings',
enableCashu
})
}
>
<ListItem.Content>
<ListItem.Title
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString('views.RecommendedSettings.fiat')}
</ListItem.Title>
<ListItem.Subtitle
style={{
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Medium',
marginTop: 4
}}
>
{fiatEnabled
? currencyLabel
: localeString(
'views.RecommendedSettings.disabled'
)}
</ListItem.Subtitle>
</ListItem.Content>
<Icon
name="keyboard-arrow-right"
color={themeColor('secondaryText')}
/>
</ListItem>
</ScrollView>
<View
style={{
padding: 20,
paddingBottom: 40
}}
>
<Button
title={localeString(
'views.RecommendedSettings.createWallet'
)}
onPress={this.handleCreateWallet}
disabled={isLoadingMints}
/>
</View>
</Screen>
);
}
}
+432
View File
@@ -0,0 +1,432 @@
import * as React from 'react';
import { View, ScrollView, Dimensions } from 'react-native';
import { inject, observer } from 'mobx-react';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Route } from '@react-navigation/native';
import { Icon, ListItem } from '@rneui/themed';
import Button from '../../components/Button';
import Header from '../../components/Header';
import LoadingIndicator from '../../components/LoadingIndicator';
import Screen from '../../components/Screen';
import Switch from '../../components/Switch';
import Text from '../../components/Text';
import SettingsStore, {
CURRENCY_KEYS,
DEFAULT_FIAT,
DEFAULT_FIAT_RATES_SOURCE,
FIAT_RATES_SOURCE_KEYS
} from '../../stores/SettingsStore';
import { createOnboardingWallet } from '../../utils/WalletCreationUtils';
import { localeString } from '../../utils/LocaleUtils';
import { themeColor } from '../../utils/ThemeUtils';
import Wordmark from '../../assets/images/SVG/wordmark-black.svg';
import DropdownSetting from '../../components/DropdownSetting';
interface WalletSettingsProps {
navigation: NativeStackNavigationProp<any, any>;
SettingsStore: SettingsStore;
route: Route<
'WalletSettings',
{
enableCashu: boolean;
returnTo?: string;
initialMintUrls?: string[];
implementation?: string;
}
>;
}
interface WalletSettingsState {
clipboard: boolean;
fiatEnabled: boolean;
selectedCurrency: string;
fiatRatesSource: string;
choosingPeers: boolean;
creatingWallet: boolean;
error: boolean;
}
@inject('SettingsStore')
@observer
export default class WalletSettings extends React.Component<
WalletSettingsProps,
WalletSettingsState
> {
state = {
clipboard: true,
fiatEnabled: true,
selectedCurrency: DEFAULT_FIAT,
fiatRatesSource: DEFAULT_FIAT_RATES_SOURCE,
choosingPeers: false,
creatingWallet: false,
error: false
};
async componentDidMount() {
const { navigation, SettingsStore } = this.props;
const settings = await SettingsStore.getSettings();
this.setState({
clipboard: settings.privacy?.clipboard ?? true,
fiatEnabled: settings.fiatEnabled ?? true,
selectedCurrency: settings.fiat || DEFAULT_FIAT,
fiatRatesSource:
settings.fiatRatesSource || DEFAULT_FIAT_RATES_SOURCE
});
// Update currency when returning from SelectCurrency
navigation.addListener('focus', async () => {
const updatedSettings = await SettingsStore.getSettings();
if (updatedSettings.fiat !== this.state.selectedCurrency) {
this.setState({
selectedCurrency: updatedSettings.fiat || DEFAULT_FIAT
});
}
});
}
handleCreateWallet = async () => {
const { navigation, SettingsStore, route } = this.props;
const { clipboard, fiatEnabled, selectedCurrency, fiatRatesSource } =
this.state;
const enableCashu = route.params?.enableCashu || false;
const initialMintUrls = route.params?.initialMintUrls;
const implementation = route.params?.implementation;
await createOnboardingWallet({
settingsStore: SettingsStore,
enableCashu,
clipboard,
fiatEnabled,
selectedCurrency,
fiatRatesSource,
initialMintUrls,
implementation,
onChoosingPeers: () =>
this.setState({ choosingPeers: true, error: false }),
onCreatingWallet: () =>
this.setState({
choosingPeers: false,
creatingWallet: true
}),
onError: () =>
this.setState({
error: true,
choosingPeers: false,
creatingWallet: false
}),
onSuccess: () => navigation.navigate('Wallet')
});
};
navigateToSelectCurrency = () => {
this.props.navigation.navigate('SelectCurrency');
};
render() {
const { navigation } = this.props;
const {
clipboard,
fiatEnabled,
selectedCurrency,
fiatRatesSource,
choosingPeers,
creatingWallet,
error
} = this.state;
if (choosingPeers || creatingWallet) {
return (
<Screen>
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
top: 10
}}
>
<View
style={{
width: Dimensions.get('window').width * 0.85,
maxHeight: 200,
marginTop: 10,
alignSelf: 'center'
}}
>
<Wordmark fill={themeColor('highlight')} />
</View>
<Text
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book',
alignSelf: 'center',
fontSize: 15,
padding: 8
}}
>
{choosingPeers
? localeString('views.Intro.choosingPeers')
: localeString(
'views.Intro.creatingWallet'
).replace('Zeus', 'ZEUS')}
</Text>
<View style={{ marginTop: 40 }}>
<LoadingIndicator />
</View>
</View>
</Screen>
);
}
return (
<Screen>
<Header
leftComponent="Back"
centerComponent={{
text: localeString('views.WalletSettings.title'),
style: {
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}
}}
navigation={navigation}
/>
<ScrollView
style={{ flex: 1, paddingHorizontal: 15, marginTop: 5 }}
keyboardShouldPersistTaps="handled"
>
{error && (
<View
style={{
backgroundColor: themeColor('error'),
padding: 15,
borderRadius: 5,
marginBottom: 15
}}
>
<Text
style={{
color: themeColor('text'),
textAlign: 'center'
}}
>
{localeString(
'views.Intro.errorCreatingWallet'
)}
</Text>
</View>
)}
<View
style={{
flexDirection: 'row',
marginTop: 20
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
color: themeColor('secondaryText'),
fontSize: 17,
fontFamily: 'PPNeueMontreal-Book'
}}
infoModalText={localeString(
'views.Settings.Privacy.clipboard.explainer'
).replace('Zeus', 'ZEUS')}
>
{localeString(
'views.Settings.Privacy.clipboard'
)}
</Text>
</View>
<View style={{ alignSelf: 'center', marginLeft: 5 }}>
<Switch
value={clipboard}
onValueChange={() => {
const newValue = !clipboard;
this.setState({ clipboard: newValue });
this.props.SettingsStore.updateSettings({
privacy: { clipboard: newValue }
});
}}
/>
</View>
</View>
<View
style={{
marginTop: 30,
borderTopWidth: 1,
borderTopColor: themeColor('separator'),
paddingTop: 20
}}
>
<ListItem
containerStyle={{
borderBottomWidth: 0,
backgroundColor: 'transparent',
paddingHorizontal: 0
}}
>
<ListItem.Title
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString(
'views.Settings.Currency.enableFiat'
)}
</ListItem.Title>
<View
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-end'
}}
>
<Switch
value={fiatEnabled}
onValueChange={() => {
const newValue = !fiatEnabled;
this.setState({
fiatEnabled: newValue
});
this.props.SettingsStore.updateSettings(
{ fiatEnabled: newValue }
);
}}
/>
</View>
</ListItem>
{fiatEnabled && (
<>
<DropdownSetting
title={
localeString(
'views.Settings.Currency.source'
) + ':'
}
selectedValue={fiatRatesSource}
onValueChange={(value: string) => {
this.setState({
fiatRatesSource: value
});
if (
!CURRENCY_KEYS.find(
(c) =>
c.value === selectedCurrency
)?.supportedSources.includes(value)
) {
this.setState({
selectedCurrency: DEFAULT_FIAT
});
}
}}
values={FIAT_RATES_SOURCE_KEYS}
/>
<ListItem
containerStyle={{
backgroundColor: 'transparent',
paddingHorizontal: 0
}}
onPress={this.navigateToSelectCurrency}
>
<ListItem.Content>
<ListItem.Title
style={{
color: themeColor(
'secondaryText'
),
fontFamily:
'PPNeueMontreal-Book'
}}
>
{(() => {
const currencyInfo =
CURRENCY_KEYS.find(
(c) =>
c.value ===
selectedCurrency
);
const flag =
currencyInfo?.flag || '';
return (
localeString(
'views.Settings.Currency.selectCurrency'
) +
` (${
flag ? `${flag} ` : ''
}${selectedCurrency})`
);
})()}
</ListItem.Title>
</ListItem.Content>
<Icon
name="keyboard-arrow-right"
color={themeColor('secondaryText')}
/>
</ListItem>
</>
)}
</View>
<View
style={{
marginTop: 30,
borderTopWidth: 1,
borderTopColor: themeColor('separator'),
paddingTop: 20
}}
>
<ListItem
containerStyle={{
backgroundColor: 'transparent',
paddingHorizontal: 0
}}
onPress={() => navigation.navigate('Security')}
>
<ListItem.Content>
<ListItem.Title
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString(
'views.Settings.Security.title'
)}
</ListItem.Title>
</ListItem.Content>
<Icon
name="keyboard-arrow-right"
color={themeColor('secondaryText')}
/>
</ListItem>
</View>
</ScrollView>
<View
style={{
padding: 20,
paddingBottom: 40
}}
>
<Button
title={localeString(
'views.WalletSettings.createWallet'
)}
onPress={this.handleCreateWallet}
/>
</View>
</Screen>
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import * as React from 'react';
import { ImageBackground, View, StyleSheet } from 'react-native';
import { observer } from 'mobx-react';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Button from '../../components/Button';
import Header from '../../components/Header';
import Screen from '../../components/Screen';
import Text from '../../components/Text';
import { font } from '../../utils/FontUtils';
import { localeString } from '../../utils/LocaleUtils';
import { themeColor } from '../../utils/ThemeUtils';
interface WalletSetupProps {
navigation: NativeStackNavigationProp<any, any>;
}
@observer
export default class WalletSetup extends React.Component<WalletSetupProps, {}> {
render() {
const { navigation } = this.props;
return (
<Screen>
<ImageBackground
source={require('../../assets/images/onboarding/temple.jpeg')}
resizeMode="cover"
style={styles.backgroundImage}
>
<View style={styles.container}>
<Header leftComponent="Back" navigation={navigation} />
<View style={styles.contentContainer}>
<View style={styles.headerContainer}>
<Text
style={{
fontSize: 32,
fontFamily: font('marlideBold'),
color: themeColor('text'),
textAlign: 'center'
}}
>
{localeString(
'views.WalletSetup.howToUseZeus'
)}
</Text>
</View>
<View style={styles.buttonsContainer}>
<View style={styles.buttonWrapper}>
<Button
title={localeString(
'views.WalletSetup.createNewWallet'
)}
onPress={() =>
navigation.navigate('WalletType')
}
/>
</View>
<View style={styles.buttonWrapper}>
<Button
title={localeString(
'views.WalletSetup.connectRemoteNode'
)}
onPress={() =>
navigation.navigate(
'WalletConfiguration',
{ newEntry: true }
)
}
secondary
/>
</View>
</View>
</View>
</View>
</ImageBackground>
</Screen>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1
},
backgroundImage: {
flex: 1,
justifyContent: 'center'
},
contentContainer: {
flex: 1,
justifyContent: 'space-between',
paddingVertical: 40
},
headerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20
},
buttonsContainer: {
paddingHorizontal: 10,
paddingBottom: 20
},
buttonWrapper: {
padding: 10
}
});
+198
View File
@@ -0,0 +1,198 @@
import * as React from 'react';
import {
ImageBackground,
View,
StyleSheet,
TouchableOpacity
} from 'react-native';
import { inject, observer } from 'mobx-react';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Route } from '@react-navigation/native';
import Button from '../../components/Button';
import Header from '../../components/Header';
import Screen from '../../components/Screen';
import Text from '../../components/Text';
import ModalStore from '../../stores/ModalStore';
import { font } from '../../utils/FontUtils';
import { localeString } from '../../utils/LocaleUtils';
import { themeColor } from '../../utils/ThemeUtils';
interface WalletTypeProps {
navigation: NativeStackNavigationProp<any, any>;
ModalStore: ModalStore;
route: Route<'WalletType', { returnTo?: string }>;
}
@inject('ModalStore')
@observer
export default class WalletType extends React.Component<WalletTypeProps, {}> {
showSelfCustodyInfo = () => {
const { ModalStore } = this.props;
ModalStore!.toggleInfoModal({
text: [
localeString('views.WalletType.selfCustody.info1'),
localeString('views.WalletType.selfCustody.info2'),
localeString('views.WalletType.selfCustody.info3')
]
});
};
showCashuInfo = () => {
const { ModalStore } = this.props;
ModalStore!.toggleInfoModal({
text: [
localeString('views.WalletType.cashu.info1'),
localeString('views.WalletType.cashu.info2'),
localeString('views.WalletType.cashu.info3')
]
});
};
handleSelect = (enableCashu: boolean) => {
const { navigation, route } = this.props;
const returnTo = route.params?.returnTo;
if (returnTo) {
navigation.navigate(returnTo, { enableCashu });
} else {
navigation.navigate('NodeChoice', { enableCashu });
}
};
render() {
const { navigation } = this.props;
return (
<Screen>
<ImageBackground
source={require('../../assets/images/onboarding/journey.jpeg')}
resizeMode="cover"
style={styles.backgroundImage}
>
<View style={styles.container}>
<Header leftComponent="Back" navigation={navigation} />
<View style={styles.contentContainer}>
<View style={styles.headerContainer}>
<Text
style={{
fontSize: 32,
fontFamily: font('marlideBold'),
color: themeColor('text'),
textAlign: 'center'
}}
>
{localeString(
'views.WalletType.whatsImportant'
)}
</Text>
</View>
<View style={styles.buttonsContainer}>
<View style={styles.optionContainer}>
<View style={styles.buttonWithInfo}>
<View style={styles.buttonFlex}>
<Button
title={localeString(
'views.WalletType.control'
)}
onPress={() =>
this.handleSelect(false)
}
/>
</View>
<TouchableOpacity
onPress={this.showSelfCustodyInfo}
style={styles.infoButton}
>
<Text
style={{
fontSize: 20,
color: themeColor('text'),
fontWeight: 'bold'
}}
>
{' ?'}
</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.optionContainer}>
<View style={styles.buttonWithInfo}>
<View style={styles.buttonFlex}>
<Button
title={localeString(
'views.WalletType.lowFees'
)}
onPress={() =>
this.handleSelect(true)
}
secondary
/>
</View>
<TouchableOpacity
onPress={this.showCashuInfo}
style={styles.infoButton}
>
<Text
style={{
fontSize: 20,
color: themeColor('text'),
fontWeight: 'bold'
}}
>
{' ?'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
</View>
</ImageBackground>
</Screen>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1
},
backgroundImage: {
flex: 1,
justifyContent: 'center'
},
contentContainer: {
flex: 1,
justifyContent: 'space-between',
paddingVertical: 40
},
headerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20
},
buttonsContainer: {
paddingHorizontal: 10,
paddingBottom: 20
},
optionContainer: {
padding: 10
},
buttonWithInfo: {
flexDirection: 'row',
alignItems: 'center'
},
buttonFlex: {
flex: 1
},
infoButton: {
padding: 10,
marginLeft: 5
}
});