mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Minibits source code - alpha version
This commit is contained in:
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* Sample React Native App
|
||||
* https://github.com/facebook/react-native
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type {PropsWithChildren} from 'react';
|
||||
import {
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useColorScheme,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import {
|
||||
Colors,
|
||||
DebugInstructions,
|
||||
Header,
|
||||
LearnMoreLinks,
|
||||
ReloadInstructions,
|
||||
} from 'react-native/Libraries/NewAppScreen';
|
||||
|
||||
type SectionProps = PropsWithChildren<{
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
function Section({children, title}: SectionProps): JSX.Element {
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
return (
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{
|
||||
color: isDarkMode ? Colors.white : Colors.black,
|
||||
},
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionDescription,
|
||||
{
|
||||
color: isDarkMode ? Colors.light : Colors.dark,
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function App(): JSX.Element {
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
|
||||
const backgroundStyle = {
|
||||
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={backgroundStyle}>
|
||||
<StatusBar
|
||||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={backgroundStyle.backgroundColor}
|
||||
/>
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
style={backgroundStyle}>
|
||||
<Header />
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.white,
|
||||
}}>
|
||||
<Section title="Step One">
|
||||
Edit <Text style={styles.highlight}>App.tsx</Text> to change this
|
||||
screen and then come back to see your edits.
|
||||
</Section>
|
||||
<Section title="See Your Changes">
|
||||
<ReloadInstructions />
|
||||
</Section>
|
||||
<Section title="Debug">
|
||||
<DebugInstructions />
|
||||
</Section>
|
||||
<Section title="Learn More">
|
||||
Read the docs to discover what to do next:
|
||||
</Section>
|
||||
<LearnMoreLinks />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sectionContainer: {
|
||||
marginTop: 32,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sectionDescription: {
|
||||
marginTop: 8,
|
||||
fontSize: 18,
|
||||
fontWeight: '400',
|
||||
},
|
||||
highlight: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default App;
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* @format
|
||||
*/
|
||||
import 'react-native-reanimated' // needed for qrcode reader
|
||||
import Crypto from 'react-native-quick-crypto' // needed for secp256k1, conf in babel.config
|
||||
import 'react-native-url-polyfill/auto' // URL.host etc
|
||||
import 'text-encoding-polyfill' // needed in cashu-ts
|
||||
import {AppRegistry} from 'react-native'
|
||||
import App from './src/App'
|
||||
import {name as appName} from './app.json'
|
||||
|
||||
import {AppRegistry} from 'react-native';
|
||||
import App from './App';
|
||||
import {name as appName} from './app.json';
|
||||
|
||||
AppRegistry.registerComponent(appName, () => App);
|
||||
function BootstrapApp() {
|
||||
return <App appName={appName} />
|
||||
}
|
||||
|
||||
AppRegistry.registerComponent(appName, () => BootstrapApp)
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
import {SENTRY_DSN} from '@env'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
} from 'react-native-safe-area-context'
|
||||
|
||||
import {AppNavigator} from './navigation'
|
||||
import {useInitialRootStore} from './models'
|
||||
import {Database} from './services'
|
||||
import {ErrorBoundary} from './screens/ErrorScreen/ErrorBoundary'
|
||||
import Config from './config'
|
||||
import {log} from './utils/logger'
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
})
|
||||
|
||||
interface AppProps {
|
||||
appName: string
|
||||
}
|
||||
|
||||
function App(props: AppProps) {
|
||||
// const isDarkMode = useColorScheme() === 'dark'
|
||||
log.info(`${props.appName} app started...`)
|
||||
|
||||
const {rehydrated} = useInitialRootStore(() => {
|
||||
// This runs after the root store has been initialized and rehydrated from storage.
|
||||
log.trace('Root store rehydrated', [], 'useInitialRootStore')
|
||||
|
||||
// This creates and opens a sqlite database that stores transactions history.
|
||||
// It triggers db migrations if database version has changed.
|
||||
// As it runs inside a callback, it should not block UI.
|
||||
const dbVersion = Database.getDatabaseVersion()
|
||||
})
|
||||
|
||||
if (!rehydrated) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<ErrorBoundary catchErrors={Config.catchErrors}>
|
||||
<AppNavigator />
|
||||
</ErrorBoundary>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useLayoutEffect, useState } from "react"
|
||||
import { Image, ImageProps, ImageURISource, Platform } from "react-native"
|
||||
|
||||
// TODO: document new props
|
||||
export interface AutoImageProps extends ImageProps {
|
||||
/**
|
||||
* How wide should the image be?
|
||||
*/
|
||||
maxWidth?: number
|
||||
/**
|
||||
* How tall should the image be?
|
||||
*/
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that will return the scaled dimensions of an image based on the
|
||||
* provided dimensions' aspect ratio. If no desired dimensions are provided,
|
||||
* it will return the original dimensions of the remote image.
|
||||
*
|
||||
* How is this different from `resizeMode: 'contain'`? Firstly, you can
|
||||
* specify only one side's size (not both). Secondly, the image will scale to fit
|
||||
* the desired dimensions instead of just being contained within its image-container.
|
||||
*
|
||||
*/
|
||||
export function useAutoImage(
|
||||
remoteUri: string,
|
||||
dimensions?: [maxWidth: number, maxHeight: number],
|
||||
): [width: number, height: number] {
|
||||
const [[remoteWidth, remoteHeight], setRemoteImageDimensions] = useState([0, 0])
|
||||
const remoteAspectRatio = remoteWidth / remoteHeight
|
||||
const [maxWidth, maxHeight] = dimensions ?? []
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!remoteUri) return
|
||||
|
||||
Image.getSize(remoteUri, (w, h) => setRemoteImageDimensions([w, h]))
|
||||
}, [remoteUri])
|
||||
|
||||
if (Number.isNaN(remoteAspectRatio)) return [0, 0]
|
||||
|
||||
if (maxWidth && maxHeight) {
|
||||
const aspectRatio = Math.min(maxWidth / remoteWidth, maxHeight / remoteHeight)
|
||||
return [remoteWidth * aspectRatio, remoteHeight * aspectRatio]
|
||||
} else if (maxWidth) {
|
||||
return [maxWidth, maxWidth / remoteAspectRatio]
|
||||
} else if (maxHeight) {
|
||||
return [maxHeight * remoteAspectRatio, maxHeight]
|
||||
} else {
|
||||
return [remoteWidth, remoteHeight]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An Image component that automatically sizes a remote or data-uri image.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-AutoImage.md)
|
||||
*/
|
||||
export function AutoImage(props: AutoImageProps) {
|
||||
const { maxWidth, maxHeight, ...ImageProps } = props
|
||||
const source = props.source as ImageURISource
|
||||
|
||||
const [width, height] = useAutoImage(
|
||||
Platform.select({
|
||||
web: (source?.uri as string) ?? (source as string),
|
||||
default: source?.uri as string,
|
||||
}),
|
||||
[maxWidth, maxHeight],
|
||||
)
|
||||
|
||||
return <Image {...ImageProps} style={[{ width, height }, props.style]} />
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { ComponentType, Fragment, ReactElement } from "react"
|
||||
import Modal from 'react-native-modal'
|
||||
import {
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
ViewProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
ColorValue,
|
||||
} from "react-native"
|
||||
import { colors, useThemeColor, spacing } from "../theme"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
|
||||
interface ModalProps extends ViewProps {
|
||||
isVisible?: boolean
|
||||
top?: number
|
||||
onBackdropPress?: any
|
||||
onBackButtonPress?: any
|
||||
backdropOpacity?: number
|
||||
/**
|
||||
* The heading text to display if not using `headingTx`.
|
||||
*/
|
||||
heading?: TextProps["text"]
|
||||
/**
|
||||
* Heading text which is looked up via i18n.
|
||||
*/
|
||||
headingTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional heading options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
headingTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for heading text.
|
||||
*/
|
||||
headingStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the heading Text component.
|
||||
*/
|
||||
HeadingTextProps?: TextProps
|
||||
/**
|
||||
* Custom heading component.
|
||||
* Overrides all other `heading*` props.
|
||||
*/
|
||||
HeadingComponent?: ReactElement
|
||||
/**
|
||||
* The content text to display if not using `contentTx`.
|
||||
*/
|
||||
content?: TextProps["text"]
|
||||
/**
|
||||
* Content text which is looked up via i18n.
|
||||
*/
|
||||
contentTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional content options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
contentTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for content text.
|
||||
*/
|
||||
contentStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the content Text component.
|
||||
*/
|
||||
ContentTextProps?: TextProps
|
||||
/**
|
||||
* Custom content component.
|
||||
* Overrides all other `content*` props.
|
||||
*/
|
||||
ContentComponent?: ReactElement
|
||||
/**
|
||||
* The footer text to display if not using `footerTx`.
|
||||
*/
|
||||
footer?: TextProps["text"]
|
||||
/**
|
||||
* Footer text which is looked up via i18n.
|
||||
*/
|
||||
footerTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional footer options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
footerTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for footer text.
|
||||
*/
|
||||
footerStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the footer Text component.
|
||||
*/
|
||||
FooterTextProps?: TextProps
|
||||
/**
|
||||
* Custom footer component.
|
||||
* Overrides all other `footer*` props.
|
||||
*/
|
||||
FooterComponent?: ReactElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal
|
||||
*/
|
||||
export function BottomModal(props: ModalProps) {
|
||||
|
||||
// TODO based on number of childs
|
||||
const defaultTop = spacing.screenHeight * 0.8
|
||||
|
||||
const {
|
||||
isVisible = true,
|
||||
top = defaultTop,
|
||||
onBackdropPress,
|
||||
onBackButtonPress,
|
||||
backdropOpacity = 0.05,
|
||||
content,
|
||||
contentTx,
|
||||
contentTxOptions,
|
||||
footer,
|
||||
footerTx,
|
||||
footerTxOptions,
|
||||
heading,
|
||||
headingTx,
|
||||
headingTxOptions,
|
||||
ContentComponent,
|
||||
HeadingComponent,
|
||||
FooterComponent,
|
||||
style: $containerStyleOverride,
|
||||
contentStyle: $contentStyleOverride,
|
||||
headingStyle: $headingStyleOverride,
|
||||
footerStyle: $footerStyleOverride,
|
||||
ContentTextProps,
|
||||
HeadingTextProps,
|
||||
FooterTextProps,
|
||||
...otherProps
|
||||
} = props
|
||||
|
||||
const $innerContainerStyle = [
|
||||
$innerContainerBase, { backgroundColor: useThemeColor('card') }, $containerStyleOverride
|
||||
]
|
||||
|
||||
const isHeadingPresent = !!(HeadingComponent || heading || headingTx)
|
||||
const isContentPresent = !!(ContentComponent || content || contentTx)
|
||||
const isFooterPresent = !!(FooterComponent || footer || footerTx)
|
||||
|
||||
const $headingStyle = [
|
||||
(isFooterPresent || isContentPresent) && { marginBottom: spacing.micro },
|
||||
$headingStyleOverride,
|
||||
HeadingTextProps?.style,
|
||||
]
|
||||
const $contentStyle = [
|
||||
isHeadingPresent && { marginTop: spacing.micro },
|
||||
isFooterPresent && { marginBottom: spacing.micro },
|
||||
$contentStyleOverride,
|
||||
ContentTextProps?.style,
|
||||
]
|
||||
const $footerStyle = [
|
||||
(isHeadingPresent || isContentPresent) && { marginTop: spacing.micro },
|
||||
$footerStyleOverride,
|
||||
FooterTextProps?.style,
|
||||
]
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isVisible={isVisible}
|
||||
// avoidKeyboard={true}
|
||||
onBackdropPress={onBackdropPress}
|
||||
onBackButtonPress={onBackButtonPress}
|
||||
backdropOpacity={backdropOpacity}
|
||||
style={[$outerContainerBase, { top }]}
|
||||
{...otherProps}
|
||||
>
|
||||
|
||||
<View style={[$innerContainerBase, $innerContainerStyle]}>
|
||||
{HeadingComponent ||
|
||||
(isHeadingPresent && (
|
||||
<Text
|
||||
weight="bold"
|
||||
text={heading}
|
||||
tx={headingTx}
|
||||
txOptions={headingTxOptions}
|
||||
{...HeadingTextProps}
|
||||
style={$headingStyle}
|
||||
/>
|
||||
))}
|
||||
|
||||
{ContentComponent ||
|
||||
(isContentPresent && (
|
||||
<Text
|
||||
weight="normal"
|
||||
text={content}
|
||||
tx={contentTx}
|
||||
txOptions={contentTxOptions}
|
||||
{...ContentTextProps}
|
||||
style={$contentStyle}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
{FooterComponent ||
|
||||
(isFooterPresent && (
|
||||
<Text
|
||||
weight="normal"
|
||||
size="xs"
|
||||
text={footer}
|
||||
tx={footerTx}
|
||||
txOptions={footerTxOptions}
|
||||
{...FooterTextProps}
|
||||
style={$footerStyle}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const $outerContainerBase: ViewStyle = {
|
||||
margin: 0,
|
||||
bottom: 0,
|
||||
borderTopLeftRadius: spacing.large,
|
||||
borderTopRightRadius: spacing.large,
|
||||
shadowColor: colors.palette.neutral900,
|
||||
//shadowOffset: { width: 10, height: -50 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 10,
|
||||
elevation: 10,
|
||||
}
|
||||
|
||||
const $innerContainerBase: ViewStyle = {
|
||||
flex: 1,
|
||||
borderTopLeftRadius: spacing.large,
|
||||
borderTopRightRadius: spacing.large,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { ComponentType } from "react"
|
||||
import {
|
||||
Pressable,
|
||||
PressableProps,
|
||||
PressableStateCallbackType,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import { colors, spacing, typography, useThemeColor } from "../theme"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
type Presets = keyof typeof $viewPresets
|
||||
|
||||
export interface ButtonAccessoryProps {
|
||||
style: StyleProp<any>
|
||||
pressableState: PressableStateCallbackType
|
||||
}
|
||||
|
||||
export interface ButtonProps extends PressableProps {
|
||||
/**
|
||||
* Text which is looked up via i18n.
|
||||
*/
|
||||
tx?: TextProps["tx"]
|
||||
/**
|
||||
* The text to display if not using `tx` or nested components.
|
||||
*/
|
||||
text?: TextProps["text"]
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
txOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* An optional style override useful for padding & margin.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* An optional style override for the "pressed" state.
|
||||
*/
|
||||
pressedStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* An optional style override for the button text.
|
||||
*/
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* An optional style override for the button text when in the "pressed" state.
|
||||
*/
|
||||
pressedTextStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* One of the different types of button presets.
|
||||
*/
|
||||
preset?: Presets
|
||||
/**
|
||||
* An optional component to render on the right side of the text.
|
||||
* Example: `RightAccessory={(props) => <View {...props} />}`
|
||||
*/
|
||||
RightAccessory?: ComponentType<ButtonAccessoryProps>
|
||||
/**
|
||||
* An optional component to render on the left side of the text.
|
||||
* Example: `LeftAccessory={(props) => <View {...props} />}`
|
||||
*/
|
||||
LeftAccessory?: ComponentType<ButtonAccessoryProps>
|
||||
/**
|
||||
* Children components.
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that allows users to take actions and make choices.
|
||||
* Wraps the Text component with a Pressable component.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Button.md)
|
||||
*/
|
||||
export function Button(props: ButtonProps) {
|
||||
const {
|
||||
tx,
|
||||
text,
|
||||
txOptions,
|
||||
preset = "default",
|
||||
style: $viewStyleOverride,
|
||||
pressedStyle: $pressedViewStyleOverride,
|
||||
textStyle: $textStyleOverride,
|
||||
pressedTextStyle: $pressedTextStyleOverride,
|
||||
children,
|
||||
RightAccessory,
|
||||
LeftAccessory,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const textColor = useThemeColor("text")
|
||||
const defaultBg = useThemeColor("button")
|
||||
const secondaryBg = useThemeColor("background")
|
||||
const tertiaryBg = "transparent"
|
||||
|
||||
function $viewStyle({ pressed }) {
|
||||
return [
|
||||
$viewPresets[preset],
|
||||
{ backgroundColor: defaultBg },
|
||||
preset === "secondary" && { backgroundColor: secondaryBg },
|
||||
preset === "tertiary" && { backgroundColor: tertiaryBg },
|
||||
$viewStyleOverride,
|
||||
!!pressed && [$pressedViewPresets[preset], $pressedViewStyleOverride],
|
||||
]
|
||||
}
|
||||
function $textStyle({ pressed }) {
|
||||
return [
|
||||
$textPresets[preset],
|
||||
{ color: textColor },
|
||||
preset === "default" && { color: 'white' },
|
||||
// $textStyleOverride,
|
||||
!!pressed && [$pressedTextPresets[preset], $pressedTextStyleOverride],
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={$viewStyle} accessibilityRole="button" {...rest}>
|
||||
{(state) => (
|
||||
<>
|
||||
{!!LeftAccessory && <LeftAccessory style={$leftAccessoryStyle} pressableState={state} />}
|
||||
|
||||
<Text tx={tx} text={text} txOptions={txOptions} style={$textStyle(state)}>
|
||||
{children}
|
||||
</Text>
|
||||
|
||||
{!!RightAccessory && (
|
||||
<RightAccessory style={$rightAccessoryStyle} pressableState={state} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const $baseViewStyle: ViewStyle = {
|
||||
minHeight: 52,
|
||||
borderRadius: 8,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
paddingVertical: spacing.small,
|
||||
paddingHorizontal: spacing.small,
|
||||
overflow: "hidden",
|
||||
}
|
||||
|
||||
const $baseTextStyle: TextStyle = {
|
||||
fontSize: 16,
|
||||
lineHeight: 20,
|
||||
fontFamily: typography.primary?.light,
|
||||
textAlign: "center",
|
||||
flexShrink: 1,
|
||||
flexGrow: 0,
|
||||
zIndex: 2,
|
||||
}
|
||||
|
||||
const $rightAccessoryStyle: ViewStyle = { marginStart: spacing.extraSmall, zIndex: 1 }
|
||||
const $leftAccessoryStyle: ViewStyle = { marginEnd: spacing.extraSmall, zIndex: 1 }
|
||||
|
||||
const $viewPresets = {
|
||||
default: [
|
||||
$baseViewStyle,
|
||||
] as StyleProp<ViewStyle>,
|
||||
|
||||
secondary: [
|
||||
$baseViewStyle,
|
||||
] as StyleProp<ViewStyle>,
|
||||
|
||||
tertiary: [
|
||||
$baseViewStyle,
|
||||
] as StyleProp<ViewStyle>,
|
||||
}
|
||||
|
||||
const $textPresets: Record<Presets, StyleProp<TextStyle>> = {
|
||||
default: $baseTextStyle,
|
||||
secondary: $baseTextStyle,
|
||||
tertiary: $baseTextStyle,
|
||||
}
|
||||
|
||||
const $pressedViewPresets: Record<Presets, StyleProp<ViewStyle>> = {
|
||||
default: { backgroundColor: colors.palette.success100 },
|
||||
secondary: { backgroundColor: colors.palette.neutral500 },
|
||||
tertiary: { backgroundColor: colors.palette.neutral500 },
|
||||
}
|
||||
|
||||
const $pressedTextPresets: Record<Presets, StyleProp<TextStyle>> = {
|
||||
default: { opacity: 0.9 },
|
||||
secondary: { opacity: 0.9 },
|
||||
tertiary: { opacity: 0.9 },
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { ComponentType, Fragment, ReactElement } from "react"
|
||||
import {
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
useColorScheme
|
||||
} from "react-native"
|
||||
import { colors, useThemeColor, spacing } from "../theme"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
type Presets = keyof typeof $containerPresets
|
||||
|
||||
interface CardProps extends TouchableOpacityProps {
|
||||
/**
|
||||
* One of the different types of text presets.
|
||||
*/
|
||||
preset?: Presets
|
||||
/**
|
||||
* How the content should be aligned vertically. This is especially (but not exclusively) useful
|
||||
* when the card is a fixed height but the content is dynamic.
|
||||
*
|
||||
* `top` (default) - aligns all content to the top.
|
||||
* `center` - aligns all content to the center.
|
||||
* `space-between` - spreads out the content evenly.
|
||||
* `force-footer-bottom` - aligns all content to the top, but forces the footer to the bottom.
|
||||
*/
|
||||
verticalAlignment?: "top" | "center" | "space-between" | "force-footer-bottom"
|
||||
/**
|
||||
* Custom component added to the left of the card body.
|
||||
*/
|
||||
LeftComponent?: ReactElement
|
||||
/**
|
||||
* Custom component added to the right of the card body.
|
||||
*/
|
||||
RightComponent?: ReactElement
|
||||
/**
|
||||
* The heading text to display if not using `headingTx`.
|
||||
*/
|
||||
heading?: TextProps["text"]
|
||||
/**
|
||||
* Heading text which is looked up via i18n.
|
||||
*/
|
||||
headingTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional heading options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
headingTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for heading text.
|
||||
*/
|
||||
headingStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the heading Text component.
|
||||
*/
|
||||
HeadingTextProps?: TextProps
|
||||
/**
|
||||
* Custom heading component.
|
||||
* Overrides all other `heading*` props.
|
||||
*/
|
||||
HeadingComponent?: ReactElement
|
||||
/**
|
||||
* The content text to display if not using `contentTx`.
|
||||
*/
|
||||
content?: TextProps["text"]
|
||||
/**
|
||||
* Content text which is looked up via i18n.
|
||||
*/
|
||||
contentTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional content options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
contentTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for content text.
|
||||
*/
|
||||
contentStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the content Text component.
|
||||
*/
|
||||
ContentTextProps?: TextProps
|
||||
/**
|
||||
* Custom content component.
|
||||
* Overrides all other `content*` props.
|
||||
*/
|
||||
ContentComponent?: ReactElement
|
||||
/**
|
||||
* The footer text to display if not using `footerTx`.
|
||||
*/
|
||||
footer?: TextProps["text"]
|
||||
/**
|
||||
* Footer text which is looked up via i18n.
|
||||
*/
|
||||
footerTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional footer options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
footerTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for footer text.
|
||||
*/
|
||||
footerStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the footer Text component.
|
||||
*/
|
||||
FooterTextProps?: TextProps
|
||||
/**
|
||||
* Custom footer component.
|
||||
* Overrides all other `footer*` props.
|
||||
*/
|
||||
FooterComponent?: ReactElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards are useful for displaying related information in a contained way.
|
||||
* If a ListItem displays content horizontally, a Card can be used to display content vertically.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Card.md)
|
||||
*/
|
||||
export function Card(props: CardProps) {
|
||||
|
||||
const backgroundColor = useThemeColor('card')
|
||||
|
||||
const {
|
||||
preset = "default",
|
||||
content,
|
||||
contentTx,
|
||||
contentTxOptions,
|
||||
footer,
|
||||
footerTx,
|
||||
footerTxOptions,
|
||||
heading,
|
||||
headingTx,
|
||||
headingTxOptions,
|
||||
ContentComponent,
|
||||
HeadingComponent,
|
||||
FooterComponent,
|
||||
LeftComponent,
|
||||
RightComponent,
|
||||
verticalAlignment = "top",
|
||||
style: $containerStyleOverride,
|
||||
contentStyle: $contentStyleOverride,
|
||||
headingStyle: $headingStyleOverride,
|
||||
footerStyle: $footerStyleOverride,
|
||||
ContentTextProps,
|
||||
HeadingTextProps,
|
||||
FooterTextProps,
|
||||
...WrapperProps
|
||||
} = props
|
||||
|
||||
const isPressable = !!WrapperProps.onPress
|
||||
const isHeadingPresent = !!(HeadingComponent || heading || headingTx)
|
||||
const isContentPresent = !!(ContentComponent || content || contentTx)
|
||||
const isFooterPresent = !!(FooterComponent || footer || footerTx)
|
||||
|
||||
const Wrapper: ComponentType<TouchableOpacityProps> = isPressable ? TouchableOpacity : View
|
||||
const HeaderContentWrapper = verticalAlignment === "force-footer-bottom" ? View : Fragment
|
||||
|
||||
const $containerStyle = [$containerPresets[preset], $containerStyleOverride]
|
||||
const $headingStyle = [
|
||||
$headingPresets[preset],
|
||||
(isFooterPresent || isContentPresent) && { marginBottom: spacing.micro },
|
||||
$headingStyleOverride,
|
||||
HeadingTextProps?.style,
|
||||
]
|
||||
const $contentStyle = [
|
||||
$contentPresets[preset],
|
||||
isHeadingPresent && { marginTop: spacing.small },
|
||||
isFooterPresent && { marginBottom: spacing.small },
|
||||
$contentStyleOverride,
|
||||
ContentTextProps?.style,
|
||||
]
|
||||
const $footerStyle = [
|
||||
$footerPresets[preset],
|
||||
(isHeadingPresent || isContentPresent) && { marginTop: spacing.micro },
|
||||
$footerStyleOverride,
|
||||
FooterTextProps?.style,
|
||||
]
|
||||
const $alignmentWrapperStyle = [
|
||||
$alignmentWrapper,
|
||||
{ justifyContent: $alignmentWrapperFlexOptions[verticalAlignment] },
|
||||
LeftComponent && { marginStart: spacing.medium },
|
||||
RightComponent && { marginEnd: spacing.medium },
|
||||
]
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
style={[$containerStyle, { backgroundColor }]}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole={isPressable ? "button" : undefined}
|
||||
{...WrapperProps}
|
||||
>
|
||||
{LeftComponent}
|
||||
|
||||
<View style={$alignmentWrapperStyle}>
|
||||
<HeaderContentWrapper>
|
||||
{HeadingComponent ||
|
||||
(isHeadingPresent && (
|
||||
<Text
|
||||
weight="bold"
|
||||
text={heading}
|
||||
tx={headingTx}
|
||||
txOptions={headingTxOptions}
|
||||
{...HeadingTextProps}
|
||||
style={$headingStyle}
|
||||
/>
|
||||
))}
|
||||
|
||||
{ContentComponent ||
|
||||
(isContentPresent && (
|
||||
<Text
|
||||
weight="normal"
|
||||
text={content}
|
||||
tx={contentTx}
|
||||
txOptions={contentTxOptions}
|
||||
{...ContentTextProps}
|
||||
style={$contentStyle}
|
||||
/>
|
||||
))}
|
||||
</HeaderContentWrapper>
|
||||
|
||||
{FooterComponent ||
|
||||
(isFooterPresent && (
|
||||
<Text
|
||||
weight="normal"
|
||||
size="xs"
|
||||
text={footer}
|
||||
tx={footerTx}
|
||||
txOptions={footerTxOptions}
|
||||
{...FooterTextProps}
|
||||
style={$footerStyle}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{RightComponent}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const $containerBase: ViewStyle = {
|
||||
borderRadius: spacing.large,
|
||||
padding: spacing.small,
|
||||
// borderWidth: 1,
|
||||
shadowColor: colors.palette.neutral600,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
minHeight: 86,
|
||||
flexDirection: "row",
|
||||
}
|
||||
|
||||
const $alignmentWrapper: ViewStyle = {
|
||||
flex: 1,
|
||||
alignSelf: "stretch",
|
||||
}
|
||||
|
||||
const $alignmentWrapperFlexOptions = {
|
||||
top: "flex-start",
|
||||
center: "center",
|
||||
"space-between": "space-between",
|
||||
"force-footer-bottom": "space-between",
|
||||
} as const
|
||||
|
||||
const $containerPresets = {
|
||||
default: [
|
||||
$containerBase,
|
||||
{
|
||||
backgroundColor: colors.palette.neutral100,
|
||||
borderColor: colors.palette.neutral300,
|
||||
},
|
||||
] as StyleProp<ViewStyle>,
|
||||
|
||||
reversed: [
|
||||
$containerBase,
|
||||
{ backgroundColor: colors.palette.neutral800, borderColor: colors.palette.neutral500 },
|
||||
] as StyleProp<ViewStyle>,
|
||||
}
|
||||
|
||||
const $headingPresets: Record<Presets, TextStyle> = {
|
||||
default: {},
|
||||
reversed: { color: colors.palette.neutral100 },
|
||||
}
|
||||
|
||||
const $contentPresets: Record<Presets, TextStyle> = {
|
||||
default: {},
|
||||
reversed: { color: colors.palette.neutral100 },
|
||||
}
|
||||
|
||||
const $footerPresets: Record<Presets, TextStyle> = {
|
||||
default: {},
|
||||
reversed: { color: colors.palette.neutral100 },
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import React from "react"
|
||||
import { Image, ImageProps, ImageStyle, StyleProp, TextStyle, View, ViewStyle } from "react-native"
|
||||
import { translate } from "../i18n"
|
||||
import { spacing } from "../theme"
|
||||
import { Button, ButtonProps } from "./Button"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
// const sadFace = require("../../assets/images/sad-face.png")
|
||||
|
||||
interface EmptyStateProps {
|
||||
/**
|
||||
* An optional prop that specifies the text/image set to use for the empty state.
|
||||
*/
|
||||
preset?: keyof typeof EmptyStatePresets
|
||||
/**
|
||||
* Style override for the container.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* An Image source to be displayed above the heading.
|
||||
*/
|
||||
imageSource?: ImageProps["source"]
|
||||
/**
|
||||
* Style overrides for image.
|
||||
*/
|
||||
imageStyle?: StyleProp<ImageStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the Image component.
|
||||
*/
|
||||
ImageProps?: Omit<ImageProps, "source">
|
||||
/**
|
||||
* The heading text to display if not using `headingTx`.
|
||||
*/
|
||||
heading?: TextProps["text"]
|
||||
/**
|
||||
* Heading text which is looked up via i18n.
|
||||
*/
|
||||
headingTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional heading options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
headingTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for heading text.
|
||||
*/
|
||||
headingStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the heading Text component.
|
||||
*/
|
||||
HeadingTextProps?: TextProps
|
||||
/**
|
||||
* The content text to display if not using `contentTx`.
|
||||
*/
|
||||
content?: TextProps["text"]
|
||||
/**
|
||||
* Content text which is looked up via i18n.
|
||||
*/
|
||||
contentTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional content options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
contentTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for content text.
|
||||
*/
|
||||
contentStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the content Text component.
|
||||
*/
|
||||
ContentTextProps?: TextProps
|
||||
/**
|
||||
* The button text to display if not using `buttonTx`.
|
||||
*/
|
||||
button?: TextProps["text"]
|
||||
/**
|
||||
* Button text which is looked up via i18n.
|
||||
*/
|
||||
buttonTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional button options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
buttonTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for button.
|
||||
*/
|
||||
buttonStyle?: ButtonProps["style"]
|
||||
/**
|
||||
* Style overrides for button text.
|
||||
*/
|
||||
buttonTextStyle?: ButtonProps["textStyle"]
|
||||
/**
|
||||
* Called when the button is pressed.
|
||||
*/
|
||||
buttonOnPress?: ButtonProps["onPress"]
|
||||
/**
|
||||
* Pass any additional props directly to the Button component.
|
||||
*/
|
||||
ButtonProps?: ButtonProps
|
||||
}
|
||||
|
||||
const EmptyStatePresets = {
|
||||
generic: {
|
||||
imageSource: null,
|
||||
heading: translate("emptyStateComponent.generic.heading"),
|
||||
content: translate("emptyStateComponent.generic.content"),
|
||||
button: translate("emptyStateComponent.generic.button"),
|
||||
},
|
||||
} as const
|
||||
|
||||
/**
|
||||
* A component to use when there is no data to display. It can be utilized to direct the user what to do next.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-EmptyState.md)
|
||||
*/
|
||||
export function EmptyState(props: EmptyStateProps) {
|
||||
const preset = EmptyStatePresets[props.preset] ? EmptyStatePresets[props.preset] : undefined
|
||||
|
||||
const {
|
||||
button = preset?.button,
|
||||
buttonTx,
|
||||
buttonOnPress,
|
||||
buttonTxOptions,
|
||||
content = preset?.content,
|
||||
contentTx,
|
||||
contentTxOptions,
|
||||
heading = preset?.heading,
|
||||
headingTx,
|
||||
headingTxOptions,
|
||||
imageSource = preset?.imageSource,
|
||||
style: $containerStyleOverride,
|
||||
buttonStyle: $buttonStyleOverride,
|
||||
buttonTextStyle: $buttonTextStyleOverride,
|
||||
contentStyle: $contentStyleOverride,
|
||||
headingStyle: $headingStyleOverride,
|
||||
imageStyle: $imageStyleOverride,
|
||||
ButtonProps,
|
||||
ContentTextProps,
|
||||
HeadingTextProps,
|
||||
ImageProps,
|
||||
} = props
|
||||
|
||||
const isImagePresent = !!imageSource
|
||||
const isHeadingPresent = !!(heading || headingTx)
|
||||
const isContentPresent = !!(content || contentTx)
|
||||
const isButtonPresent = !!(button || buttonTx)
|
||||
|
||||
const $containerStyles = [$containerStyleOverride]
|
||||
const $imageStyles = [
|
||||
$image,
|
||||
(isHeadingPresent || isContentPresent || isButtonPresent) && { marginBottom: spacing.micro },
|
||||
$imageStyleOverride,
|
||||
ImageProps?.style,
|
||||
]
|
||||
const $headingStyles = [
|
||||
$heading,
|
||||
isImagePresent && { marginTop: spacing.micro },
|
||||
(isContentPresent || isButtonPresent) && { marginBottom: spacing.micro },
|
||||
$headingStyleOverride,
|
||||
HeadingTextProps?.style,
|
||||
]
|
||||
const $contentStyles = [
|
||||
$content,
|
||||
(isImagePresent || isHeadingPresent) && { marginTop: spacing.micro },
|
||||
isButtonPresent && { marginBottom: spacing.micro },
|
||||
$contentStyleOverride,
|
||||
ContentTextProps?.style,
|
||||
]
|
||||
const $buttonStyles = [
|
||||
(isImagePresent || isHeadingPresent || isContentPresent) && { marginTop: spacing.extraLarge },
|
||||
$buttonStyleOverride,
|
||||
ButtonProps?.style,
|
||||
]
|
||||
|
||||
return (
|
||||
<View style={$containerStyles}>
|
||||
{isImagePresent && <Image source={imageSource} {...ImageProps} style={$imageStyles} />}
|
||||
|
||||
{isHeadingPresent && (
|
||||
<Text
|
||||
preset="subheading"
|
||||
text={heading}
|
||||
tx={headingTx}
|
||||
txOptions={headingTxOptions}
|
||||
{...HeadingTextProps}
|
||||
style={$headingStyles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isContentPresent && (
|
||||
<Text
|
||||
text={content}
|
||||
tx={contentTx}
|
||||
txOptions={contentTxOptions}
|
||||
{...ContentTextProps}
|
||||
style={$contentStyles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isButtonPresent && (
|
||||
<Button
|
||||
onPress={buttonOnPress}
|
||||
text={button}
|
||||
tx={buttonTx}
|
||||
txOptions={buttonTxOptions}
|
||||
textStyle={$buttonTextStyleOverride}
|
||||
{...ButtonProps}
|
||||
style={$buttonStyles}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $image: ImageStyle = { alignSelf: "center" }
|
||||
const $heading: TextStyle = { textAlign: "center", paddingHorizontal: spacing.large }
|
||||
const $content: TextStyle = { textAlign: "center", paddingHorizontal: spacing.large }
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { FC, useState, useEffect } from 'react'
|
||||
import { View, ScrollView } from 'react-native'
|
||||
import { BottomModal, Icon, Text } from '../components'
|
||||
import AppError from '../utils/AppError'
|
||||
import { spacing, useThemeColor, colors } from '../theme'
|
||||
|
||||
|
||||
type ErrorModalProps = {
|
||||
error: AppError
|
||||
}
|
||||
|
||||
export const ErrorModal: FC<ErrorModalProps> = function ({ error }) {
|
||||
|
||||
|
||||
const [isErrorVisible, setIsErrorVisible] = useState<boolean>(true)
|
||||
|
||||
// needed for error to re-appear
|
||||
useEffect(() => {
|
||||
setIsErrorVisible(true)
|
||||
}, [error])
|
||||
|
||||
|
||||
const onClose = () => {
|
||||
setIsErrorVisible(false)
|
||||
}
|
||||
|
||||
const backgroundColor = useThemeColor('error')
|
||||
|
||||
return (
|
||||
<BottomModal
|
||||
isVisible={isErrorVisible}
|
||||
onBackdropPress={onClose}
|
||||
onBackButtonPress={onClose}
|
||||
style={{ backgroundColor, padding: spacing.small }}
|
||||
top={spacing.screenHeight * 0.7}
|
||||
ContentComponent={
|
||||
<>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.small }}>
|
||||
<Icon icon="faInfoCircle" size={spacing.large} color="white" />
|
||||
<Text style={{ color: 'white', marginLeft: spacing.small }}>{error.name}</Text>
|
||||
</View>
|
||||
<ScrollView>
|
||||
<Text style={{ color: 'white', marginBottom: spacing.small }}>{error.message}</Text>
|
||||
<Text style={{ color: colors.dark.textDim }} size="xs">{JSON.stringify(error.params)}</Text>
|
||||
</ScrollView>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import React, { ReactElement } from "react"
|
||||
import {
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
useColorScheme,
|
||||
StatusBar,
|
||||
StatusBarProps,
|
||||
ColorValue
|
||||
} from "react-native"
|
||||
import { useIsFocused } from '@react-navigation/native'
|
||||
import { translate } from "../i18n"
|
||||
import { useThemeColor, spacing, colors } from "../theme"
|
||||
import { displayName as appName } from '../../app.json'
|
||||
import { ExtendedEdge, useSafeAreaInsetsStyle } from "../utils/useSafeAreaInsetsStyle"
|
||||
import { Icon, IconTypes } from "./Icon"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
export interface HeaderProps {
|
||||
/**
|
||||
* The layout of the title relative to the action components.
|
||||
* - `center` will force the title to always be centered relative to the header. If the title or the action buttons are too long, the title will be cut off.
|
||||
* - `flex` will attempt to center the title relative to the action buttons. If the action buttons are different widths, the title will be off-center relative to the header.
|
||||
*/
|
||||
titleMode?: "center" | "flex"
|
||||
/**
|
||||
* Optional title style override.
|
||||
*/
|
||||
titleStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Optional outer title container style override.
|
||||
*/
|
||||
titleContainerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Optional inner header wrapper style override.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Optional outer header container style override.
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Background color
|
||||
*/
|
||||
backgroundColor?: ColorValue
|
||||
/**
|
||||
* Title text to display if not using `tx` or nested components.
|
||||
*/
|
||||
title?: TextProps["text"]
|
||||
/**
|
||||
* Title text which is looked up via i18n.
|
||||
*/
|
||||
titleTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
titleTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Icon that should appear on the left.
|
||||
* Can be used with `onLeftPress`.
|
||||
*/
|
||||
leftIcon?: IconTypes
|
||||
/**
|
||||
* An optional tint color for the left icon
|
||||
*/
|
||||
leftIconColor?: string
|
||||
/**
|
||||
* Left action text to display if not using `leftTx`.
|
||||
* Can be used with `onLeftPress`. Overrides `leftIcon`.
|
||||
*/
|
||||
leftText?: TextProps["text"]
|
||||
/**
|
||||
* Left action text text which is looked up via i18n.
|
||||
* Can be used with `onLeftPress`. Overrides `leftIcon`.
|
||||
*/
|
||||
leftTx?: TextProps["tx"]
|
||||
/**
|
||||
* Left action custom ReactElement if the built in action props don't suffice.
|
||||
* Overrides `leftIcon`, `leftTx` and `leftText`.
|
||||
*/
|
||||
LeftActionComponent?: ReactElement
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
leftTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* What happens when you press the left icon or text action.
|
||||
*/
|
||||
onLeftPress?: TouchableOpacityProps["onPress"]
|
||||
/**
|
||||
* Icon that should appear on the right.
|
||||
* Can be used with `onRightPress`.
|
||||
*/
|
||||
rightIcon?: IconTypes
|
||||
/**
|
||||
* An optional tint color for the right icon
|
||||
*/
|
||||
rightIconColor?: ColorValue
|
||||
/**
|
||||
* Right action text to display if not using `rightTx`.
|
||||
* Can be used with `onRightPress`. Overrides `rightIcon`.
|
||||
*/
|
||||
rightText?: TextProps["text"]
|
||||
/**
|
||||
* Right action text text which is looked up via i18n.
|
||||
* Can be used with `onRightPress`. Overrides `rightIcon`.
|
||||
*/
|
||||
rightTx?: TextProps["tx"]
|
||||
/**
|
||||
* Right action custom ReactElement if the built in action props don't suffice.
|
||||
* Overrides `rightIcon`, `rightTx` and `rightText`.
|
||||
*/
|
||||
RightActionComponent?: ReactElement
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
rightTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* What happens when you press the right icon or text action.
|
||||
*/
|
||||
onRightPress?: TouchableOpacityProps["onPress"]
|
||||
/**
|
||||
* Override the default edges for the safe area.
|
||||
*/
|
||||
safeAreaEdges?: ExtendedEdge[]
|
||||
/**
|
||||
* Pass any additional props directly to the StatusBar component.
|
||||
*/
|
||||
StatusBarProps?: StatusBarProps
|
||||
}
|
||||
|
||||
interface HeaderActionProps {
|
||||
backgroundColor?: ColorValue
|
||||
icon?: IconTypes
|
||||
iconColor?: string
|
||||
text?: TextProps["text"]
|
||||
tx?: TextProps["tx"]
|
||||
txOptions?: TextProps["txOptions"]
|
||||
onPress?: TouchableOpacityProps["onPress"]
|
||||
ActionComponent?: ReactElement
|
||||
}
|
||||
|
||||
|
||||
/* Needed to keep status bar styles on navigation tabs */
|
||||
function FocusAwareStatusBar(props: StatusBarProps) {
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
return isFocused ? <StatusBar {...props} /> : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header that appears on many screens. Will hold navigation buttons and screen title.
|
||||
* The Header is meant to be used with the `screenOptions.header` option on navigators, routes, or screen components via `navigation.setOptions({ header })`.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Header.md)
|
||||
*/
|
||||
export function Header(props: HeaderProps) {
|
||||
const colorScheme = useColorScheme()
|
||||
|
||||
const {
|
||||
backgroundColor = useThemeColor('header'),
|
||||
LeftActionComponent,
|
||||
leftIcon,
|
||||
leftIconColor = "white",
|
||||
leftText,
|
||||
leftTx,
|
||||
leftTxOptions,
|
||||
onLeftPress,
|
||||
onRightPress,
|
||||
RightActionComponent,
|
||||
rightIcon,
|
||||
rightIconColor = "white",
|
||||
rightText,
|
||||
rightTx,
|
||||
rightTxOptions,
|
||||
safeAreaEdges = ["top"],
|
||||
title= appName,
|
||||
titleMode = "center",
|
||||
titleTx,
|
||||
titleTxOptions,
|
||||
titleContainerStyle: $titleContainerStyleOverride,
|
||||
style: $styleOverride,
|
||||
titleStyle: $titleStyleOverride,
|
||||
containerStyle: $containerStyleOverride,
|
||||
StatusBarProps,
|
||||
} = props
|
||||
|
||||
const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
|
||||
const titleContent = titleTx ? translate(titleTx, titleTxOptions) : title
|
||||
|
||||
return (
|
||||
<View style={[$container, $containerInsets, { backgroundColor }, $containerStyleOverride]}>
|
||||
<FocusAwareStatusBar
|
||||
backgroundColor={backgroundColor}
|
||||
// barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
|
||||
barStyle = 'light-content'
|
||||
{...StatusBarProps}
|
||||
/>
|
||||
<View style={[$wrapper, $styleOverride]}>
|
||||
<HeaderAction
|
||||
tx={leftTx}
|
||||
text={leftText}
|
||||
icon={leftIcon}
|
||||
iconColor={leftIconColor as string}
|
||||
onPress={onLeftPress}
|
||||
txOptions={leftTxOptions}
|
||||
backgroundColor={backgroundColor}
|
||||
ActionComponent={LeftActionComponent}
|
||||
/>
|
||||
|
||||
{!!titleContent && (
|
||||
<View
|
||||
style={[
|
||||
titleMode === "center" && $titleWrapperCenter,
|
||||
titleMode === "flex" && $titleWrapperFlex,
|
||||
$titleContainerStyleOverride,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Text
|
||||
weight="medium"
|
||||
size="lg"
|
||||
text={titleContent}
|
||||
style={[$title, $titleStyleOverride]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<HeaderAction
|
||||
tx={rightTx}
|
||||
text={rightText}
|
||||
icon={rightIcon}
|
||||
iconColor={rightIconColor as string}
|
||||
onPress={onRightPress}
|
||||
txOptions={rightTxOptions}
|
||||
backgroundColor={backgroundColor}
|
||||
ActionComponent={RightActionComponent}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderAction(props: HeaderActionProps) {
|
||||
const { backgroundColor, icon, text, tx, txOptions, onPress, ActionComponent, iconColor } = props
|
||||
|
||||
const content = tx ? translate(tx, txOptions) : text
|
||||
|
||||
if (ActionComponent) return ActionComponent
|
||||
|
||||
if (content) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[$actionTextContainer, { backgroundColor }]}
|
||||
onPress={onPress}
|
||||
disabled={!onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text weight="medium" size="md" text={content} style={$actionText} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
return (
|
||||
<Icon
|
||||
size={20}
|
||||
icon={icon}
|
||||
color={iconColor}
|
||||
onPress={onPress}
|
||||
containerStyle={[$actionIconContainer, { backgroundColor }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <View style={[$actionFillerContainer, { backgroundColor }]} />
|
||||
}
|
||||
|
||||
const $wrapper: ViewStyle = {
|
||||
height: 56,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}
|
||||
|
||||
const $container: ViewStyle = {
|
||||
width: "100%",
|
||||
}
|
||||
|
||||
const $title: TextStyle = {
|
||||
textAlign: "center",
|
||||
fontFamily: 'Gluten-Regular',
|
||||
color: 'white'
|
||||
}
|
||||
|
||||
const $actionTextContainer: ViewStyle = {
|
||||
flexGrow: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
paddingHorizontal: spacing.medium,
|
||||
zIndex: 2,
|
||||
}
|
||||
|
||||
const $actionText: TextStyle = {
|
||||
color: colors.light.tint,
|
||||
}
|
||||
|
||||
const $actionIconContainer: ViewStyle = {
|
||||
flexGrow: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
paddingHorizontal: spacing.medium,
|
||||
zIndex: 2,
|
||||
}
|
||||
|
||||
const $actionFillerContainer: ViewStyle = {
|
||||
width: 16,
|
||||
}
|
||||
|
||||
const $titleWrapperCenter: ViewStyle = {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
position: "absolute",
|
||||
paddingHorizontal: spacing.huge,
|
||||
zIndex: 1,
|
||||
}
|
||||
|
||||
const $titleWrapperFlex: ViewStyle = {
|
||||
justifyContent: "center",
|
||||
flexGrow: 1,
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import * as React from "react"
|
||||
import { ComponentType } from "react"
|
||||
import {
|
||||
ColorValue,
|
||||
Image,
|
||||
ImageStyle,
|
||||
StyleProp,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'
|
||||
import { colors, spacing, useThemeColor } from "../theme"
|
||||
import { IconDefinition, Transform } from "@fortawesome/fontawesome-svg-core"
|
||||
|
||||
|
||||
import { faWallet } from '@fortawesome/free-solid-svg-icons/faWallet'
|
||||
import { faAddressCard } from '@fortawesome/free-solid-svg-icons/faAddressCard'
|
||||
import { faAddressBook } from '@fortawesome/free-solid-svg-icons/faAddressBook'
|
||||
import { faQrcode } from '@fortawesome/free-solid-svg-icons/faQrcode'
|
||||
import { faClipboard } from '@fortawesome/free-solid-svg-icons/faClipboard'
|
||||
import { faSliders } from '@fortawesome/free-solid-svg-icons/faSliders'
|
||||
import { faCoins } from '@fortawesome/free-solid-svg-icons/faCoins'
|
||||
import { faEllipsisVertical } from '@fortawesome/free-solid-svg-icons/faEllipsisVertical'
|
||||
import { faEllipsis } from '@fortawesome/free-solid-svg-icons/faEllipsis'
|
||||
import { faArrowUp } from '@fortawesome/free-solid-svg-icons/faArrowUp'
|
||||
import { faArrowDown } from '@fortawesome/free-solid-svg-icons/faArrowDown'
|
||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons/faArrowLeft'
|
||||
import { faXmark } from '@fortawesome/free-solid-svg-icons/faXmark'
|
||||
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons/faInfoCircle'
|
||||
import { faBug } from '@fortawesome/free-solid-svg-icons/faBug'
|
||||
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons/faCheckCircle'
|
||||
import { faArrowTurnUp } from '@fortawesome/free-solid-svg-icons/faArrowTurnUp'
|
||||
import { faArrowTurnDown } from '@fortawesome/free-solid-svg-icons/faArrowTurnDown'
|
||||
import { faPencil } from '@fortawesome/free-solid-svg-icons/faPencil'
|
||||
import { faTags } from '@fortawesome/free-solid-svg-icons/faTags'
|
||||
import { faShareFromSquare } from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
|
||||
import { faRotate } from '@fortawesome/free-solid-svg-icons/faRotate'
|
||||
import { faCode } from '@fortawesome/free-solid-svg-icons/faCode'
|
||||
import { faBan } from '@fortawesome/free-solid-svg-icons/faBan'
|
||||
import { faCircle } from '@fortawesome/free-solid-svg-icons/faCircle'
|
||||
import { faPaperPlane } from '@fortawesome/free-solid-svg-icons/faPaperPlane'
|
||||
import { faBolt } from '@fortawesome/free-solid-svg-icons/faBolt'
|
||||
import { faArrowUpFromBracket } from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
|
||||
import { faArrowRightToBracket } from '@fortawesome/free-solid-svg-icons/faArrowRightToBracket'
|
||||
import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus'
|
||||
import { faShieldHalved } from '@fortawesome/free-solid-svg-icons/faShieldHalved'
|
||||
import { faCloudArrowUp } from '@fortawesome/free-solid-svg-icons/faCloudArrowUp'
|
||||
import { faPaintbrush } from '@fortawesome/free-solid-svg-icons/faPaintbrush'
|
||||
import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy'
|
||||
import { faBurst } from '@fortawesome/free-solid-svg-icons/faBurst'
|
||||
import { faUserShield } from '@fortawesome/free-solid-svg-icons/faUserShield'
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons/faLock'
|
||||
import { faLockOpen } from '@fortawesome/free-solid-svg-icons/faLockOpen'
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons/faTriangleExclamation'
|
||||
import { faDownload } from '@fortawesome/free-solid-svg-icons/faDownload'
|
||||
import { faUpload } from '@fortawesome/free-solid-svg-icons/faUpload'
|
||||
import { faRecycle } from '@fortawesome/free-solid-svg-icons/faRecycle'
|
||||
import { faListUl } from '@fortawesome/free-solid-svg-icons/faListUl'
|
||||
import { faExpand } from '@fortawesome/free-solid-svg-icons/faExpand'
|
||||
|
||||
export type IconTypes = keyof typeof iconRegistry
|
||||
|
||||
export const iconRegistry = {
|
||||
faAddressCard: faAddressCard,
|
||||
faAddressBook: faAddressBook,
|
||||
faWallet: faWallet,
|
||||
faQrcode: faQrcode,
|
||||
faClipboard: faClipboard,
|
||||
faSliders: faSliders,
|
||||
faCoins: faCoins,
|
||||
faEllipsisVertical: faEllipsisVertical,
|
||||
faEllipsis: faEllipsis,
|
||||
faArrowUp: faArrowUp,
|
||||
faArrowDown: faArrowDown,
|
||||
faArrowLeft: faArrowLeft,
|
||||
faXmark: faXmark,
|
||||
faInfoCircle: faInfoCircle,
|
||||
faBug: faBug,
|
||||
faCheckCircle: faCheckCircle,
|
||||
faArrowTurnUp: faArrowTurnUp,
|
||||
faArrowTurnDown: faArrowTurnDown,
|
||||
faPencil: faPencil,
|
||||
faTags: faTags,
|
||||
faShareFromSquare: faShareFromSquare,
|
||||
faRotate: faRotate,
|
||||
faCode: faCode,
|
||||
faBan: faBan,
|
||||
faCircle: faCircle,
|
||||
faPaperPlane: faPaperPlane,
|
||||
faBolt: faBolt,
|
||||
faArrowUpFromBracket: faArrowUpFromBracket,
|
||||
faArrowRightToBracket: faArrowRightToBracket,
|
||||
faPlus: faPlus,
|
||||
faShieldHalved: faShieldHalved,
|
||||
faCloudArrowUp: faCloudArrowUp,
|
||||
faPaintbrush: faPaintbrush,
|
||||
faCopy: faCopy,
|
||||
faBurst: faBurst,
|
||||
faUserShield: faUserShield,
|
||||
faLock: faLock,
|
||||
faLockOpen: faLockOpen,
|
||||
faTriangleExclamation: faTriangleExclamation,
|
||||
faDownload: faDownload,
|
||||
faUpload: faUpload,
|
||||
faRecycle: faRecycle,
|
||||
faListUl: faListUl,
|
||||
faExpand: faExpand,
|
||||
}
|
||||
|
||||
|
||||
interface IconProps extends TouchableOpacityProps {
|
||||
/**
|
||||
* The name of the icon
|
||||
*/
|
||||
icon: IconTypes
|
||||
|
||||
/**
|
||||
* An optional tint color for the icon
|
||||
*/
|
||||
color?: ColorValue
|
||||
|
||||
/**
|
||||
* An optional size for the icon. If not provided, the icon will be sized to the icon's resolution.
|
||||
*/
|
||||
size?: number
|
||||
|
||||
/**
|
||||
* An inverse style with white icon on colored rounded background.
|
||||
*/
|
||||
inverse?: boolean
|
||||
|
||||
/**
|
||||
* Style overrides for the icon image
|
||||
*/
|
||||
style?: StyleProp<ImageStyle>
|
||||
|
||||
/**
|
||||
* Style overrides for the icon container
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
|
||||
/**
|
||||
* Transform style
|
||||
*/
|
||||
transform?: string | Transform | undefined
|
||||
|
||||
/**
|
||||
* An optional function to be called when the icon is pressed
|
||||
*/
|
||||
onPress?: TouchableOpacityProps["onPress"]
|
||||
}
|
||||
|
||||
/**
|
||||
* A component to render a registered icon.
|
||||
* It is wrapped in a <TouchableOpacity /> if `onPress` is provided, otherwise a <View />.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Icon.md)
|
||||
*/
|
||||
export function Icon(props: IconProps) {
|
||||
const {
|
||||
icon,
|
||||
color = useThemeColor('text'),
|
||||
size,
|
||||
inverse = false,
|
||||
transform,
|
||||
style: $imageStyleOverride,
|
||||
containerStyle: $containerStyleOverride,
|
||||
...WrapperProps
|
||||
} = props
|
||||
|
||||
const isPressable = !!WrapperProps.onPress
|
||||
const Wrapper: ComponentType<TouchableOpacityProps> = WrapperProps?.onPress
|
||||
? TouchableOpacity
|
||||
: View
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
accessibilityRole={isPressable ? "imagebutton" : undefined}
|
||||
{...WrapperProps}
|
||||
style={inverse ? [$inverseContainer, {backgroundColor: color}, $containerStyleOverride] : $containerStyleOverride}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={iconRegistry[icon]}
|
||||
size={size}
|
||||
color={inverse ? 'white' : color as string}
|
||||
transform={transform}
|
||||
/>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const $imageStyle: ImageStyle = {
|
||||
resizeMode: "contain",
|
||||
}
|
||||
|
||||
const $inverseContainer: ImageStyle = {
|
||||
borderRadius: spacing.small,
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: "center",
|
||||
marginRight: spacing.medium,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { FC, useState, useEffect } from 'react'
|
||||
import { View } from 'react-native'
|
||||
import { BottomModal, Icon, Text } from '../components'
|
||||
import { spacing, useThemeColor } from '../theme'
|
||||
|
||||
|
||||
type InfoModalProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InfoModal: FC<InfoModalProps> = function ({ message }) {
|
||||
|
||||
const [isInfoVisible, setIsInfoVisible] = useState<boolean>(true)
|
||||
|
||||
// needed for info to re-appear
|
||||
useEffect(() => {
|
||||
setIsInfoVisible(true)
|
||||
// setTimeout(() => setIsInfoVisible(false), 3000)
|
||||
}, [message])
|
||||
|
||||
|
||||
const onClose = () => {
|
||||
setIsInfoVisible(false)
|
||||
}
|
||||
|
||||
const backgroundColor = useThemeColor('info')
|
||||
|
||||
return (
|
||||
<BottomModal
|
||||
isVisible={isInfoVisible}
|
||||
onBackdropPress={() => onClose()}
|
||||
onBackButtonPress={() => onClose()}
|
||||
style={{ padding: spacing.medium, backgroundColor }}
|
||||
top={spacing.screenHeight - 120}
|
||||
ContentComponent={
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center'}}>
|
||||
<Icon icon="faInfoCircle" size={spacing.large} color="white" />
|
||||
<Text style={{ marginHorizontal: spacing.small }}>{message}</Text>
|
||||
</View>
|
||||
}
|
||||
>
|
||||
</BottomModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { ReactElement } from "react"
|
||||
import { ColorValue, useColorScheme } from "react-native"
|
||||
import {
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import { useThemeColor, spacing } from "../theme"
|
||||
import { Icon, IconTypes } from "./Icon"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
export interface ListItemProps extends TouchableOpacityProps {
|
||||
/**
|
||||
* How tall the list item should be.
|
||||
* Default: 56
|
||||
*/
|
||||
height?: number
|
||||
/**
|
||||
* Whether to show the top separator.
|
||||
* Default: false
|
||||
*/
|
||||
topSeparator?: boolean
|
||||
/**
|
||||
* Whether to show the bottom separator.
|
||||
* Default: false
|
||||
*/
|
||||
bottomSeparator?: boolean
|
||||
/**
|
||||
* Text to display if not using `tx` or nested components.
|
||||
*/
|
||||
text?: TextProps["text"]
|
||||
/**
|
||||
* Text which is looked up via i18n.
|
||||
*/
|
||||
tx?: TextProps["tx"]
|
||||
/**
|
||||
* Sub text to display if not using `tx` or nested components.
|
||||
*/
|
||||
subText?: TextProps["text"]
|
||||
/**
|
||||
* Sub text which is looked up via i18n.
|
||||
*/
|
||||
subTx?: TextProps["tx"]
|
||||
/**
|
||||
* Children components.
|
||||
*/
|
||||
children?: TextProps["children"]
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
txOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Optional text style override.
|
||||
*/
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the Text component.
|
||||
*/
|
||||
TextProps?: TextProps
|
||||
/**
|
||||
* Optional View container style override.
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Optional TouchableOpacity style override.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Icon that should appear on the left.
|
||||
*/
|
||||
leftIcon?: IconTypes
|
||||
/**
|
||||
* An optional tint color for the left icon
|
||||
*/
|
||||
leftIconColor?: string
|
||||
leftIconTransform?: string
|
||||
/**
|
||||
* Icon that should appear on the right.
|
||||
*/
|
||||
rightIcon?: IconTypes
|
||||
/**
|
||||
* An optional tint color for the right icon
|
||||
*/
|
||||
rightIconColor?: string
|
||||
rightIconTransform?: string
|
||||
/**
|
||||
* Right action custom ReactElement.
|
||||
* Overrides `rightIcon`.
|
||||
*/
|
||||
RightComponent?: ReactElement
|
||||
/**
|
||||
* Left action custom ReactElement.
|
||||
* Overrides `leftIcon`.
|
||||
*/
|
||||
LeftComponent?: ReactElement
|
||||
}
|
||||
|
||||
interface ListItemActionProps {
|
||||
icon: IconTypes | undefined
|
||||
iconColor?: ColorValue
|
||||
iconTransform?: string
|
||||
Component?: ReactElement
|
||||
size: number
|
||||
side: "left" | "right"
|
||||
}
|
||||
|
||||
/**
|
||||
* A styled row component that can be used in FlatList, SectionList, or by itself.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-ListItem.md)
|
||||
*/
|
||||
export function ListItem(props: ListItemProps) {
|
||||
const colorScheme = useColorScheme()
|
||||
|
||||
const {
|
||||
bottomSeparator,
|
||||
children,
|
||||
height = 56,
|
||||
LeftComponent,
|
||||
leftIcon,
|
||||
leftIconColor = useThemeColor('textDim'),
|
||||
leftIconTransform,
|
||||
RightComponent,
|
||||
rightIcon,
|
||||
rightIconColor = useThemeColor('textDim'),
|
||||
rightIconTransform,
|
||||
style,
|
||||
text,
|
||||
subText,
|
||||
TextProps,
|
||||
topSeparator,
|
||||
tx,
|
||||
subTx,
|
||||
txOptions,
|
||||
textStyle: $textStyleOverride,
|
||||
containerStyle: $containerStyleOverride,
|
||||
...TouchableOpacityProps
|
||||
} = props
|
||||
|
||||
const $textStyles = [$textStyle, $textStyleOverride, TextProps?.style]
|
||||
|
||||
const separatorColor = useThemeColor('separator')
|
||||
const subTextColor = useThemeColor('textDim')
|
||||
|
||||
const $subTextStyles = [$subTextStyle, TextProps?.style]
|
||||
|
||||
const $containerStyles = [
|
||||
topSeparator && $separatorTop, { borderTopColor: separatorColor },
|
||||
bottomSeparator && $separatorBottom, { borderBottomColor: separatorColor },
|
||||
$containerStyleOverride,
|
||||
]
|
||||
|
||||
const $touchableStyles = [$touchableStyle, { minHeight: height }, style]
|
||||
|
||||
return (
|
||||
<View style={$containerStyles}>
|
||||
<TouchableOpacity {...TouchableOpacityProps} style={$touchableStyles}>
|
||||
<ListItemAction
|
||||
side="left"
|
||||
size={height}
|
||||
icon={leftIcon}
|
||||
iconColor={leftIconColor}
|
||||
iconTransform={leftIconTransform}
|
||||
Component={LeftComponent}
|
||||
/>
|
||||
{(subText || subTx) ? (
|
||||
<View style={$subTextContainer}>
|
||||
<Text {...TextProps} tx={tx} text={text} txOptions={txOptions} style={[$textStyles, {alignSelf: 'flex-start'}]}>
|
||||
{children}
|
||||
</Text>
|
||||
<Text {...TextProps} size="xs" tx={subTx} text={subText} txOptions={txOptions} style={[$subTextStyles, {color: subTextColor}]}>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text {...TextProps} tx={tx} text={text} txOptions={txOptions} style={$textStyles}>
|
||||
{children}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<ListItemAction
|
||||
side="right"
|
||||
size={height}
|
||||
icon={rightIcon}
|
||||
iconColor={rightIconColor}
|
||||
iconTransform={rightIconTransform}
|
||||
Component={RightComponent}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ListItemAction(props: ListItemActionProps) {
|
||||
const { icon, Component, iconColor, iconTransform, size, side } = props
|
||||
|
||||
const $iconContainerStyles = [$iconContainer]
|
||||
|
||||
if (Component) return Component
|
||||
|
||||
if (icon) {
|
||||
return (
|
||||
<Icon
|
||||
size={20}
|
||||
icon={icon}
|
||||
color={iconColor}
|
||||
transform={iconTransform}
|
||||
containerStyle={[
|
||||
$iconContainerStyles,
|
||||
side === "left" && $iconContainerLeft,
|
||||
side === "right" && $iconContainerRight,
|
||||
{ height: size },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const $separatorTop: ViewStyle = {
|
||||
borderTopWidth: 1,
|
||||
}
|
||||
|
||||
const $separatorBottom: ViewStyle = {
|
||||
borderBottomWidth: 1,
|
||||
}
|
||||
|
||||
const $textStyle: TextStyle = {
|
||||
paddingVertical: spacing.extraSmall,
|
||||
alignSelf: "center",
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
}
|
||||
|
||||
const $subTextContainer: TextStyle = {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
// borderColor: 'red',
|
||||
// borderWidth: 1,
|
||||
}
|
||||
|
||||
const $subTextStyle: TextStyle = {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
paddingBottom: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $touchableStyle: ViewStyle = {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
}
|
||||
|
||||
const $iconContainer: ViewStyle = {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexGrow: 0,
|
||||
}
|
||||
const $iconContainerLeft: ViewStyle = {
|
||||
marginEnd: spacing.medium,
|
||||
}
|
||||
|
||||
const $iconContainerRight: ViewStyle = {
|
||||
marginStart: spacing.medium,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import { StyleSheet, View, ActivityIndicator, ViewProps } from 'react-native'
|
||||
import { useThemeColor } from '../theme'
|
||||
|
||||
export function Loading(props: ViewProps) {
|
||||
return (
|
||||
<View style={[{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
backgroundColor: useThemeColor('background'),
|
||||
opacity: 0.5,
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}, StyleSheet.absoluteFillObject]}>
|
||||
<ActivityIndicator color="#ccc" animating size="large" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useScrollToTop } from "@react-navigation/native"
|
||||
import { useIsFocused } from '@react-navigation/native'
|
||||
import React, { useRef, useState } from "react"
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
KeyboardAvoidingViewProps,
|
||||
LayoutChangeEvent,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ScrollViewProps,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
useColorScheme,
|
||||
StatusBar,
|
||||
StatusBarProps
|
||||
} from "react-native"
|
||||
|
||||
import { useThemeColor, spacing } from "../theme"
|
||||
import { ExtendedEdge, useSafeAreaInsetsStyle } from "../utils/useSafeAreaInsetsStyle"
|
||||
|
||||
interface BaseScreenProps {
|
||||
/**
|
||||
* Children components.
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
/**
|
||||
* Style for the outer content container useful for padding & margin.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Style for the inner content container useful for padding & margin.
|
||||
*/
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Override the default edges for the safe area.
|
||||
*/
|
||||
safeAreaEdges?: ExtendedEdge[]
|
||||
/**
|
||||
* Background color
|
||||
*/
|
||||
backgroundColor?: string
|
||||
/**
|
||||
* By how much should we offset the keyboard? Defaults to 0.
|
||||
*/
|
||||
keyboardOffset?: number
|
||||
/**
|
||||
* Pass any additional props directly to the KeyboardAvoidingView component.
|
||||
*/
|
||||
KeyboardAvoidingViewProps?: KeyboardAvoidingViewProps
|
||||
}
|
||||
|
||||
interface FixedScreenProps extends BaseScreenProps {
|
||||
preset?: "fixed"
|
||||
}
|
||||
interface ScrollScreenProps extends BaseScreenProps {
|
||||
preset?: "scroll"
|
||||
/**
|
||||
* Should keyboard persist on screen tap. Defaults to handled.
|
||||
* Only applies to scroll preset.
|
||||
*/
|
||||
keyboardShouldPersistTaps?: "handled" | "always" | "never"
|
||||
/**
|
||||
* Pass any additional props directly to the ScrollView component.
|
||||
*/
|
||||
ScrollViewProps?: ScrollViewProps
|
||||
}
|
||||
|
||||
interface AutoScreenProps extends Omit<ScrollScreenProps, "preset"> {
|
||||
preset?: "auto"
|
||||
/**
|
||||
* Threshold to trigger the automatic disabling/enabling of scroll ability.
|
||||
* Defaults to `{ percent: 0.92 }`.
|
||||
*/
|
||||
scrollEnabledToggleThreshold?: { percent?: number; point?: number }
|
||||
}
|
||||
|
||||
export type ScreenProps = ScrollScreenProps | FixedScreenProps | AutoScreenProps
|
||||
|
||||
const isIos = Platform.OS === "ios"
|
||||
|
||||
function isNonScrolling(preset?: ScreenProps["preset"]) {
|
||||
return !preset || preset === "fixed"
|
||||
}
|
||||
|
||||
function useAutoPreset(props: AutoScreenProps) {
|
||||
const { preset, scrollEnabledToggleThreshold } = props
|
||||
const { percent = 0.92, point = 0 } = scrollEnabledToggleThreshold || {}
|
||||
|
||||
const scrollViewHeight = useRef(null)
|
||||
const scrollViewContentHeight = useRef(null)
|
||||
const [scrollEnabled, setScrollEnabled] = useState(true)
|
||||
|
||||
function updateScrollState() {
|
||||
if (scrollViewHeight.current === null || scrollViewContentHeight.current === null) return
|
||||
|
||||
// check whether content fits the screen then toggle scroll state according to it
|
||||
const contentFitsScreen = (function () {
|
||||
if (point) {
|
||||
return scrollViewContentHeight.current < scrollViewHeight.current - point
|
||||
} else {
|
||||
return scrollViewContentHeight.current < scrollViewHeight.current * percent
|
||||
}
|
||||
})()
|
||||
|
||||
// content is less than the size of the screen, so we can disable scrolling
|
||||
if (scrollEnabled && contentFitsScreen) setScrollEnabled(false)
|
||||
|
||||
// content is greater than the size of the screen, so let's enable scrolling
|
||||
if (!scrollEnabled && !contentFitsScreen) setScrollEnabled(true)
|
||||
}
|
||||
|
||||
function onContentSizeChange(w: number, h: number) {
|
||||
// update scroll-view content height
|
||||
scrollViewContentHeight.current = h
|
||||
updateScrollState()
|
||||
}
|
||||
|
||||
function onLayout(e: LayoutChangeEvent) {
|
||||
const { height } = e.nativeEvent.layout
|
||||
// update scroll-view height
|
||||
scrollViewHeight.current = height
|
||||
updateScrollState()
|
||||
}
|
||||
|
||||
// update scroll state on every render
|
||||
if (preset === "auto") updateScrollState()
|
||||
|
||||
return {
|
||||
scrollEnabled: preset === "auto" ? scrollEnabled : true,
|
||||
onContentSizeChange,
|
||||
onLayout,
|
||||
}
|
||||
}
|
||||
|
||||
function ScreenWithoutScrolling(props: ScreenProps) {
|
||||
const { style, contentContainerStyle, children } = props
|
||||
return (
|
||||
<View style={[$outerStyle, style]}>
|
||||
<View style={[$innerStyle, contentContainerStyle]}>{children}</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ScreenWithScrolling(props: ScreenProps) {
|
||||
const {
|
||||
children,
|
||||
keyboardShouldPersistTaps = "handled",
|
||||
contentContainerStyle,
|
||||
ScrollViewProps,
|
||||
style,
|
||||
} = props as ScrollScreenProps
|
||||
|
||||
const ref = useRef<ScrollView>()
|
||||
|
||||
const { scrollEnabled, onContentSizeChange, onLayout } = useAutoPreset(props as AutoScreenProps)
|
||||
|
||||
// Add native behavior of pressing the active tab to scroll to the top of the content
|
||||
// More info at: https://reactnavigation.org/docs/use-scroll-to-top/
|
||||
useScrollToTop(ref)
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
{...{ keyboardShouldPersistTaps, scrollEnabled, ref }}
|
||||
{...ScrollViewProps}
|
||||
onLayout={(e) => {
|
||||
onLayout(e)
|
||||
ScrollViewProps?.onLayout?.(e)
|
||||
}}
|
||||
onContentSizeChange={(w: number, h: number) => {
|
||||
onContentSizeChange(w, h)
|
||||
ScrollViewProps?.onContentSizeChange?.(w, h)
|
||||
}}
|
||||
style={[$outerStyle, ScrollViewProps?.style, style]}
|
||||
contentContainerStyle={[
|
||||
$innerStyle,
|
||||
ScrollViewProps?.contentContainerStyle,
|
||||
contentContainerStyle,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export function Screen(props: ScreenProps) {
|
||||
const {
|
||||
backgroundColor = useThemeColor('background'),
|
||||
KeyboardAvoidingViewProps,
|
||||
keyboardOffset = 0,
|
||||
safeAreaEdges,
|
||||
} = props
|
||||
|
||||
const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
|
||||
|
||||
return (
|
||||
<View style={[$containerStyle, { backgroundColor }, $containerInsets]}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={isIos ? "padding" : undefined}
|
||||
keyboardVerticalOffset={keyboardOffset}
|
||||
{...KeyboardAvoidingViewProps}
|
||||
style={[$keyboardAvoidingViewStyle, KeyboardAvoidingViewProps?.style]}
|
||||
>
|
||||
{isNonScrolling(props.preset) ? (
|
||||
<ScreenWithoutScrolling {...props} />
|
||||
) : (
|
||||
<ScreenWithScrolling {...props} />
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $containerStyle: ViewStyle = {
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}
|
||||
|
||||
const $keyboardAvoidingViewStyle: ViewStyle = {
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $outerStyle: ViewStyle = {
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}
|
||||
|
||||
const $innerStyle: ViewStyle = {
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "stretch",
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { I18nOptions } from "i18n-js"
|
||||
import { i18n } from "../i18n/i18n"
|
||||
import React from "react"
|
||||
import { StyleProp, Text as RNText, TextProps as RNTextProps, TextStyle, useColorScheme } from "react-native"
|
||||
import { translate, TxKeyPath } from "../i18n"
|
||||
import { useThemeColor, typography } from "../theme"
|
||||
|
||||
type Sizes = keyof typeof $sizeStyles
|
||||
type Weights = keyof typeof typography.primary
|
||||
type Presets = keyof typeof $presets
|
||||
|
||||
export interface TextProps extends RNTextProps {
|
||||
/**
|
||||
* Text which is looked up via i18n.
|
||||
*/
|
||||
tx?: TxKeyPath
|
||||
/**
|
||||
* The text to display if not using `tx` or nested components.
|
||||
*/
|
||||
text?: string
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
txOptions?: I18nOptions
|
||||
/**
|
||||
* An optional style override useful for padding & margin.
|
||||
*/
|
||||
style?: StyleProp<TextStyle>
|
||||
/**
|
||||
* One of the different types of text presets.
|
||||
*/
|
||||
preset?: Presets
|
||||
/**
|
||||
* Text weight modifier.
|
||||
*/
|
||||
weight?: Weights
|
||||
/**
|
||||
* Text size modifier.
|
||||
*/
|
||||
size?: Sizes
|
||||
/**
|
||||
* Children components.
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* For your text displaying needs.
|
||||
* This component is a HOC over the built-in React Native one.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Text.md)
|
||||
*/
|
||||
export function Text(props: TextProps) {
|
||||
const { weight, size, tx, txOptions, text, children, style: $styleOverride, ...rest } = props
|
||||
|
||||
const i18nText = tx && translate(tx, txOptions)
|
||||
const content = i18nText || text || children
|
||||
|
||||
const preset: Presets = $presets[props.preset] ? props.preset : "default"
|
||||
const $styles = [
|
||||
$presets[preset],
|
||||
weight ? {fontFamily: weight} : null,
|
||||
size ? $sizeStyles[size] : null,
|
||||
{ color: useThemeColor('text') },
|
||||
$styleOverride,
|
||||
]
|
||||
|
||||
const $baseFontFamily = { fontFamily: typography.primary } as TextStyle
|
||||
|
||||
return (
|
||||
<RNText {...rest} style={[$baseFontFamily, $styles]}>
|
||||
{content}
|
||||
</RNText>
|
||||
)
|
||||
}
|
||||
|
||||
const $sizeStyles = {
|
||||
xxl: { fontSize: 36, lineHeight: 44 } as TextStyle,
|
||||
xl: { fontSize: 24, lineHeight: 34 } as TextStyle,
|
||||
lg: { fontSize: 20, lineHeight: 32 } as TextStyle,
|
||||
md: { fontSize: 18, lineHeight: 26 } as TextStyle,
|
||||
sm: { fontSize: 16, lineHeight: 24 } as TextStyle,
|
||||
xs: { fontSize: 14, lineHeight: 21 } as TextStyle,
|
||||
xxs: { fontSize: 12, lineHeight: 18 } as TextStyle,
|
||||
}
|
||||
|
||||
|
||||
// does not work
|
||||
/* const $fontWeightStyles = Object.entries(typography.primary).reduce((acc, [weight, fontFamily]) => {
|
||||
return { ...acc, [weight]: { fontFamily } }
|
||||
}, {}) as Record<Weights, TextStyle> */
|
||||
|
||||
const $baseStyle: StyleProp<TextStyle> = [
|
||||
$sizeStyles.sm,
|
||||
{fontFamily: typography.primary?.normal}
|
||||
]
|
||||
|
||||
const $presets = {
|
||||
default: $baseStyle,
|
||||
|
||||
bold: [$baseStyle, {fontFamily: typography.primary?.medium}] as StyleProp<TextStyle>,
|
||||
|
||||
heading: [$baseStyle, $sizeStyles.xxl, {fontFamily: typography.primary?.medium}] as StyleProp<TextStyle>,
|
||||
|
||||
subheading: [$baseStyle, $sizeStyles.lg] as StyleProp<TextStyle>,
|
||||
|
||||
formLabel: [$baseStyle] as StyleProp<TextStyle>,
|
||||
|
||||
formHelper: [$baseStyle, $sizeStyles.sm, {fontFamily: typography.primary?.light}] as StyleProp<TextStyle>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import React, { ComponentType, forwardRef, Ref, useImperativeHandle, useRef } from "react"
|
||||
import {
|
||||
StyleProp,
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import { isRTL, translate } from "../i18n"
|
||||
import { colors, spacing, typography } from "../theme"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
export interface TextFieldAccessoryProps {
|
||||
style: StyleProp<any>
|
||||
status: TextFieldProps["status"]
|
||||
multiline: boolean
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export interface TextFieldProps extends Omit<TextInputProps, "ref"> {
|
||||
/**
|
||||
* A style modifier for different input states.
|
||||
*/
|
||||
status?: "error" | "disabled"
|
||||
/**
|
||||
* The label text to display if not using `labelTx`.
|
||||
*/
|
||||
label?: TextProps["text"]
|
||||
/**
|
||||
* Label text which is looked up via i18n.
|
||||
*/
|
||||
labelTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional label options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
labelTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Pass any additional props directly to the label Text component.
|
||||
*/
|
||||
LabelTextProps?: TextProps
|
||||
/**
|
||||
* The helper text to display if not using `helperTx`.
|
||||
*/
|
||||
helper?: TextProps["text"]
|
||||
/**
|
||||
* Helper text which is looked up via i18n.
|
||||
*/
|
||||
helperTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional helper options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
helperTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Pass any additional props directly to the helper Text component.
|
||||
*/
|
||||
HelperTextProps?: TextProps
|
||||
/**
|
||||
* The placeholder text to display if not using `placeholderTx`.
|
||||
*/
|
||||
placeholder?: TextProps["text"]
|
||||
/**
|
||||
* Placeholder text which is looked up via i18n.
|
||||
*/
|
||||
placeholderTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional placeholder options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
placeholderTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Optional input style override.
|
||||
*/
|
||||
style?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Style overrides for the container
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Style overrides for the input wrapper
|
||||
*/
|
||||
inputWrapperStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* An optional component to render on the right side of the input.
|
||||
* Example: `RightAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} color={props.editable ? colors.textDim : colors.text} />}`
|
||||
* Note: It is a good idea to memoize this.
|
||||
*/
|
||||
RightAccessory?: ComponentType<TextFieldAccessoryProps>
|
||||
/**
|
||||
* An optional component to render on the left side of the input.
|
||||
* Example: `LeftAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} color={props.editable ? colors.textDim : colors.text} />}`
|
||||
* Note: It is a good idea to memoize this.
|
||||
*/
|
||||
LeftAccessory?: ComponentType<TextFieldAccessoryProps>
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that allows for the entering and editing of text.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-TextField.md)
|
||||
*/
|
||||
export const TextField = forwardRef(function TextField(props: TextFieldProps, ref: Ref<TextInput>) {
|
||||
const {
|
||||
labelTx,
|
||||
label,
|
||||
labelTxOptions,
|
||||
placeholderTx,
|
||||
placeholder,
|
||||
placeholderTxOptions,
|
||||
helper,
|
||||
helperTx,
|
||||
helperTxOptions,
|
||||
status,
|
||||
RightAccessory,
|
||||
LeftAccessory,
|
||||
HelperTextProps,
|
||||
LabelTextProps,
|
||||
style: $inputStyleOverride,
|
||||
containerStyle: $containerStyleOverride,
|
||||
inputWrapperStyle: $inputWrapperStyleOverride,
|
||||
...TextInputProps
|
||||
} = props
|
||||
const input = useRef<TextInput>()
|
||||
|
||||
const disabled = TextInputProps.editable === false || status === "disabled"
|
||||
|
||||
const placeholderContent = placeholderTx
|
||||
? translate(placeholderTx, placeholderTxOptions)
|
||||
: placeholder
|
||||
|
||||
const $containerStyles = [$containerStyleOverride]
|
||||
|
||||
const $labelStyles = [$labelStyle, LabelTextProps?.style]
|
||||
|
||||
const $inputWrapperStyles = [
|
||||
$inputWrapperStyle,
|
||||
status === "error" && { borderColor: colors.light.error },
|
||||
TextInputProps.multiline && { minHeight: 112 },
|
||||
LeftAccessory && { paddingStart: 0 },
|
||||
RightAccessory && { paddingEnd: 0 },
|
||||
$inputWrapperStyleOverride,
|
||||
]
|
||||
|
||||
const $inputStyles = [
|
||||
$inputStyle,
|
||||
disabled && { color: colors.light.textDim },
|
||||
isRTL && { textAlign: "right" as TextStyle["textAlign"] },
|
||||
TextInputProps.multiline && { height: "auto" },
|
||||
$inputStyleOverride,
|
||||
]
|
||||
|
||||
const $helperStyles = [
|
||||
$helperStyle,
|
||||
status === "error" && { color: colors.light.error },
|
||||
HelperTextProps?.style,
|
||||
]
|
||||
|
||||
function focusInput() {
|
||||
if (disabled) return
|
||||
|
||||
input.current?.focus()
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => input.current)
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={$containerStyles}
|
||||
onPress={focusInput}
|
||||
accessibilityState={{ disabled }}
|
||||
>
|
||||
{!!(label || labelTx) && (
|
||||
<Text
|
||||
preset="formLabel"
|
||||
text={label}
|
||||
tx={labelTx}
|
||||
txOptions={labelTxOptions}
|
||||
{...LabelTextProps}
|
||||
style={$labelStyles}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={$inputWrapperStyles}>
|
||||
{!!LeftAccessory && (
|
||||
<LeftAccessory
|
||||
style={$leftAccessoryStyle}
|
||||
status={status}
|
||||
editable={!disabled}
|
||||
multiline={TextInputProps.multiline}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
ref={input}
|
||||
underlineColorAndroid={colors.light.transparent}
|
||||
textAlignVertical="top"
|
||||
placeholder={placeholderContent}
|
||||
placeholderTextColor={colors.light.textDim}
|
||||
{...TextInputProps}
|
||||
editable={!disabled}
|
||||
style={$inputStyles}
|
||||
/>
|
||||
|
||||
{!!RightAccessory && (
|
||||
<RightAccessory
|
||||
style={$rightAccessoryStyle}
|
||||
status={status}
|
||||
editable={!disabled}
|
||||
multiline={TextInputProps.multiline}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!!(helper || helperTx) && (
|
||||
<Text
|
||||
preset="formHelper"
|
||||
text={helper}
|
||||
tx={helperTx}
|
||||
txOptions={helperTxOptions}
|
||||
{...HelperTextProps}
|
||||
style={$helperStyles}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})
|
||||
|
||||
const $labelStyle: TextStyle = {
|
||||
marginBottom: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $inputWrapperStyle: ViewStyle = {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.palette.neutral200,
|
||||
borderColor: colors.palette.neutral400,
|
||||
overflow: "hidden",
|
||||
}
|
||||
|
||||
const $inputStyle: TextStyle = {
|
||||
flex: 1,
|
||||
alignSelf: "stretch",
|
||||
fontFamily: typography.primary?.normal,
|
||||
color: colors.light.text,
|
||||
fontSize: 16,
|
||||
height: 24,
|
||||
// https://github.com/facebook/react-native/issues/21720#issuecomment-532642093
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: 0,
|
||||
marginVertical: spacing.extraSmall,
|
||||
marginHorizontal: spacing.small,
|
||||
}
|
||||
|
||||
const $helperStyle: TextStyle = {
|
||||
marginTop: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $rightAccessoryStyle: ViewStyle = {
|
||||
marginEnd: spacing.extraSmall,
|
||||
height: 40,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}
|
||||
const $leftAccessoryStyle: ViewStyle = {
|
||||
marginStart: spacing.extraSmall,
|
||||
height: 40,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import React, { ComponentType, FC, useMemo } from "react"
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
Image,
|
||||
ImageStyle,
|
||||
StyleProp,
|
||||
SwitchProps,
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import Animated, { useAnimatedStyle, withTiming } from "react-native-reanimated"
|
||||
import { colors, spacing } from "../theme"
|
||||
import { iconRegistry, IconTypes } from "./Icon"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
type Variants = "checkbox" | "switch" | "radio"
|
||||
|
||||
interface BaseToggleProps extends Omit<TouchableOpacityProps, "style"> {
|
||||
/**
|
||||
* The variant of the toggle.
|
||||
* Options: "checkbox", "switch", "radio"
|
||||
* Default: "checkbox"
|
||||
*/
|
||||
variant?: unknown
|
||||
/**
|
||||
* A style modifier for different input states.
|
||||
*/
|
||||
status?: "error" | "disabled"
|
||||
/**
|
||||
* If false, input is not editable. The default value is true.
|
||||
*/
|
||||
editable?: TextInputProps["editable"]
|
||||
/**
|
||||
* The value of the field. If true the component will be turned on.
|
||||
*/
|
||||
value?: boolean
|
||||
/**
|
||||
* Invoked with the new value when the value changes.
|
||||
*/
|
||||
onValueChange?: SwitchProps["onValueChange"]
|
||||
/**
|
||||
* Style overrides for the container
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Style overrides for the input wrapper
|
||||
*/
|
||||
inputWrapperStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Optional input wrapper style override.
|
||||
* This gives the inputs their size, shape, "off" background-color, and outer border.
|
||||
*/
|
||||
inputOuterStyle?: ViewStyle
|
||||
/**
|
||||
* Optional input style override.
|
||||
* This gives the inputs their inner characteristics and "on" background-color.
|
||||
*/
|
||||
inputInnerStyle?: ViewStyle
|
||||
/**
|
||||
* The position of the label relative to the action component.
|
||||
* Default: right
|
||||
*/
|
||||
labelPosition?: "left" | "right"
|
||||
/**
|
||||
* The label text to display if not using `labelTx`.
|
||||
*/
|
||||
label?: TextProps["text"]
|
||||
/**
|
||||
* Label text which is looked up via i18n.
|
||||
*/
|
||||
labelTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional label options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
labelTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for label text.
|
||||
*/
|
||||
labelStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the label Text component.
|
||||
*/
|
||||
LabelTextProps?: TextProps
|
||||
/**
|
||||
* The helper text to display if not using `helperTx`.
|
||||
*/
|
||||
helper?: TextProps["text"]
|
||||
/**
|
||||
* Helper text which is looked up via i18n.
|
||||
*/
|
||||
helperTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional helper options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
helperTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Pass any additional props directly to the helper Text component.
|
||||
*/
|
||||
HelperTextProps?: TextProps
|
||||
}
|
||||
|
||||
interface CheckboxToggleProps extends BaseToggleProps {
|
||||
variant?: "checkbox"
|
||||
/**
|
||||
* Optional style prop that affects the Image component.
|
||||
*/
|
||||
inputDetailStyle?: ImageStyle
|
||||
/**
|
||||
* Checkbox-only prop that changes the icon used for the "on" state.
|
||||
*/
|
||||
checkboxIcon?: IconTypes
|
||||
}
|
||||
|
||||
interface RadioToggleProps extends BaseToggleProps {
|
||||
variant?: "radio"
|
||||
/**
|
||||
* Optional style prop that affects the dot View.
|
||||
*/
|
||||
inputDetailStyle?: ViewStyle
|
||||
}
|
||||
|
||||
interface SwitchToggleProps extends BaseToggleProps {
|
||||
variant?: "switch"
|
||||
/**
|
||||
* Switch-only prop that adds a text/icon label for on/off states.
|
||||
*/
|
||||
switchAccessibilityMode?: "text" | "icon"
|
||||
/**
|
||||
* Optional style prop that affects the knob View.
|
||||
* Note: `width` and `height` rules should be points (numbers), not percentages.
|
||||
*/
|
||||
inputDetailStyle?: Omit<ViewStyle, "width" | "height"> & { width?: number; height?: number }
|
||||
}
|
||||
|
||||
export type ToggleProps = CheckboxToggleProps | RadioToggleProps | SwitchToggleProps
|
||||
|
||||
interface ToggleInputProps {
|
||||
on: boolean
|
||||
status: BaseToggleProps["status"]
|
||||
disabled: boolean
|
||||
outerStyle: ViewStyle
|
||||
innerStyle: ViewStyle
|
||||
detailStyle: Omit<ViewStyle & ImageStyle, "overflow">
|
||||
switchAccessibilityMode?: SwitchToggleProps["switchAccessibilityMode"]
|
||||
checkboxIcon?: CheckboxToggleProps["checkboxIcon"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a boolean input.
|
||||
* This is a controlled component that requires an onValueChange callback that updates the value prop in order for the component to reflect user actions. If the value prop is not updated, the component will continue to render the supplied value prop instead of the expected result of any user actions.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Toggle.md)
|
||||
*/
|
||||
export function Toggle(props: ToggleProps) {
|
||||
const {
|
||||
variant = "checkbox",
|
||||
editable = true,
|
||||
status,
|
||||
value,
|
||||
onPress,
|
||||
onValueChange,
|
||||
labelPosition = "right",
|
||||
helper,
|
||||
helperTx,
|
||||
helperTxOptions,
|
||||
HelperTextProps,
|
||||
containerStyle: $containerStyleOverride,
|
||||
inputWrapperStyle: $inputWrapperStyleOverride,
|
||||
...WrapperProps
|
||||
} = props
|
||||
|
||||
const { switchAccessibilityMode } = props as SwitchToggleProps
|
||||
const { checkboxIcon } = props as CheckboxToggleProps
|
||||
|
||||
const disabled = editable === false || status === "disabled" || props.disabled
|
||||
|
||||
const Wrapper = useMemo<ComponentType<TouchableOpacityProps>>(
|
||||
() => (disabled ? View : TouchableOpacity),
|
||||
[disabled],
|
||||
)
|
||||
const ToggleInput = useMemo(() => ToggleInputs[variant] || (() => null), [variant])
|
||||
|
||||
const $containerStyles = [$containerStyleOverride]
|
||||
const $inputWrapperStyles = [$inputWrapper, $inputWrapperStyleOverride]
|
||||
const $helperStyles = [
|
||||
$helper,
|
||||
status === "error" && { color: colors.error },
|
||||
HelperTextProps?.style,
|
||||
]
|
||||
|
||||
function handlePress(e: GestureResponderEvent) {
|
||||
if (disabled) return
|
||||
onValueChange?.(!value)
|
||||
onPress?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
activeOpacity={1}
|
||||
accessibilityRole={variant}
|
||||
accessibilityState={{ checked: value, disabled }}
|
||||
{...WrapperProps}
|
||||
style={$containerStyles}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<View style={$inputWrapperStyles}>
|
||||
{labelPosition === "left" && <FieldLabel {...props} labelPosition={labelPosition} />}
|
||||
|
||||
<ToggleInput
|
||||
on={value}
|
||||
disabled={disabled}
|
||||
status={status}
|
||||
outerStyle={props.inputOuterStyle}
|
||||
innerStyle={props.inputInnerStyle}
|
||||
detailStyle={props.inputDetailStyle}
|
||||
switchAccessibilityMode={switchAccessibilityMode}
|
||||
checkboxIcon={checkboxIcon}
|
||||
/>
|
||||
|
||||
{labelPosition === "right" && <FieldLabel {...props} labelPosition={labelPosition} />}
|
||||
</View>
|
||||
|
||||
{!!(helper || helperTx) && (
|
||||
<Text
|
||||
preset="formHelper"
|
||||
text={helper}
|
||||
tx={helperTx}
|
||||
txOptions={helperTxOptions}
|
||||
{...HelperTextProps}
|
||||
style={$helperStyles}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const ToggleInputs: Record<Variants, FC<ToggleInputProps>> = {
|
||||
checkbox: Checkbox,
|
||||
switch: Switch,
|
||||
radio: Radio,
|
||||
}
|
||||
|
||||
function Checkbox(props: ToggleInputProps) {
|
||||
const {
|
||||
on,
|
||||
status,
|
||||
disabled,
|
||||
checkboxIcon,
|
||||
outerStyle: $outerStyleOverride,
|
||||
innerStyle: $innerStyleOverride,
|
||||
detailStyle: $detailStyleOverride,
|
||||
} = props
|
||||
|
||||
const offBackgroundColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.neutral200,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const outerBorderColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.error,
|
||||
!on && colors.palette.neutral800,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const onBackgroundColor = [
|
||||
disabled && colors.transparent,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const iconTintColor = [
|
||||
disabled && colors.palette.neutral600,
|
||||
status === "error" && colors.error,
|
||||
colors.palette.accent100,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
$inputOuterVariants.checkbox,
|
||||
{ backgroundColor: offBackgroundColor, borderColor: outerBorderColor },
|
||||
$outerStyleOverride,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
$checkboxInner,
|
||||
{ backgroundColor: onBackgroundColor },
|
||||
$innerStyleOverride,
|
||||
useAnimatedStyle(() => ({ opacity: withTiming(on ? 1 : 0) }), [on]),
|
||||
]}
|
||||
>
|
||||
<Image
|
||||
source={iconRegistry[checkboxIcon] || iconRegistry.check}
|
||||
style={[$checkboxDetail, { tintColor: iconTintColor }, $detailStyleOverride]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Radio(props: ToggleInputProps) {
|
||||
const {
|
||||
on,
|
||||
status,
|
||||
disabled,
|
||||
outerStyle: $outerStyleOverride,
|
||||
innerStyle: $innerStyleOverride,
|
||||
detailStyle: $detailStyleOverride,
|
||||
} = props
|
||||
|
||||
const offBackgroundColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.neutral200,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const outerBorderColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.error,
|
||||
!on && colors.palette.neutral800,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const onBackgroundColor = [
|
||||
disabled && colors.transparent,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.neutral100,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const dotBackgroundColor = [
|
||||
disabled && colors.palette.neutral600,
|
||||
status === "error" && colors.error,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
$inputOuterVariants.radio,
|
||||
{ backgroundColor: offBackgroundColor, borderColor: outerBorderColor },
|
||||
$outerStyleOverride,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
$radioInner,
|
||||
{ backgroundColor: onBackgroundColor },
|
||||
$innerStyleOverride,
|
||||
useAnimatedStyle(() => ({ opacity: withTiming(on ? 1 : 0) }), [on]),
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[$radioDetail, { backgroundColor: dotBackgroundColor }, $detailStyleOverride]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Switch(props: ToggleInputProps) {
|
||||
const {
|
||||
on,
|
||||
status,
|
||||
disabled,
|
||||
outerStyle: $outerStyleOverride,
|
||||
innerStyle: $innerStyleOverride,
|
||||
detailStyle: $detailStyleOverride,
|
||||
} = props
|
||||
|
||||
const knobSizeFallback = 2
|
||||
|
||||
const knobWidth = [$detailStyleOverride?.width, $switchDetail?.width, knobSizeFallback].find(
|
||||
(v) => typeof v === "number",
|
||||
)
|
||||
|
||||
const knobHeight = [$detailStyleOverride?.height, $switchDetail?.height, knobSizeFallback].find(
|
||||
(v) => typeof v === "number",
|
||||
)
|
||||
|
||||
const offBackgroundColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.neutral300,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const onBackgroundColor = [
|
||||
disabled && colors.transparent,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const knobBackgroundColor = (function () {
|
||||
if (on) {
|
||||
return [
|
||||
$detailStyleOverride?.backgroundColor,
|
||||
status === "error" && colors.error,
|
||||
disabled && colors.palette.neutral600,
|
||||
colors.palette.neutral100,
|
||||
].filter(Boolean)[0]
|
||||
} else {
|
||||
return [
|
||||
$innerStyleOverride?.backgroundColor,
|
||||
disabled && colors.palette.neutral600,
|
||||
status === "error" && colors.error,
|
||||
colors.palette.neutral200,
|
||||
].filter(Boolean)[0]
|
||||
}
|
||||
})()
|
||||
|
||||
const $animatedSwitchKnob = useAnimatedStyle(() => {
|
||||
const offsetLeft = ($innerStyleOverride?.paddingStart ||
|
||||
$innerStyleOverride?.paddingLeft ||
|
||||
$switchInner?.paddingStart ||
|
||||
$switchInner?.paddingLeft ||
|
||||
0) as number
|
||||
|
||||
const offsetRight = ($innerStyleOverride?.paddingEnd ||
|
||||
$innerStyleOverride?.paddingRight ||
|
||||
$switchInner?.paddingEnd ||
|
||||
$switchInner?.paddingRight ||
|
||||
0) as number
|
||||
|
||||
const start = withTiming(on ? "100%" : "0%")
|
||||
const marginStart = withTiming(on ? -(knobWidth || 0) - offsetRight : 0 + offsetLeft)
|
||||
|
||||
return { start, marginStart }
|
||||
}, [on, knobWidth])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
$inputOuterVariants.switch,
|
||||
{ backgroundColor: offBackgroundColor },
|
||||
$outerStyleOverride,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
$switchInner,
|
||||
{ backgroundColor: onBackgroundColor },
|
||||
$innerStyleOverride,
|
||||
useAnimatedStyle(() => ({ opacity: withTiming(on ? 1 : 0) }), [on]),
|
||||
]}
|
||||
/>
|
||||
|
||||
<SwitchAccessibilityLabel {...props} role="on" />
|
||||
<SwitchAccessibilityLabel {...props} role="off" />
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
$switchDetail,
|
||||
$detailStyleOverride,
|
||||
$animatedSwitchKnob,
|
||||
{ width: knobWidth, height: knobHeight },
|
||||
{ backgroundColor: knobBackgroundColor },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SwitchAccessibilityLabel(props: ToggleInputProps & { role: "on" | "off" }) {
|
||||
const { on, disabled, status, switchAccessibilityMode, role, innerStyle, detailStyle } = props
|
||||
|
||||
if (!switchAccessibilityMode) return null
|
||||
|
||||
const shouldLabelBeVisible = (on && role === "on") || (!on && role === "off")
|
||||
|
||||
const $switchAccessibilityStyle = [
|
||||
$switchAccessibility,
|
||||
role === "off" && { end: "5%" },
|
||||
role === "on" && { left: "5%" },
|
||||
]
|
||||
|
||||
const color = (function () {
|
||||
if (disabled) return colors.palette.neutral600
|
||||
if (status === "error") return colors.error
|
||||
if (!on) return innerStyle?.backgroundColor || colors.palette.secondary500
|
||||
return detailStyle?.backgroundColor || colors.palette.neutral100
|
||||
})()
|
||||
|
||||
return (
|
||||
<View style={$switchAccessibilityStyle}>
|
||||
{switchAccessibilityMode === "text" && shouldLabelBeVisible && (
|
||||
<View
|
||||
style={[
|
||||
role === "on" && $switchAccessibilityLine,
|
||||
role === "on" && { backgroundColor: color },
|
||||
role === "off" && $switchAccessibilityCircle,
|
||||
role === "off" && { borderColor: color },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{switchAccessibilityMode === "icon" && shouldLabelBeVisible && (
|
||||
<Image
|
||||
style={[$switchAccessibilityIcon, { tintColor: color }]}
|
||||
source={role === "off" ? iconRegistry.hidden : iconRegistry.view}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel(props: BaseToggleProps) {
|
||||
const {
|
||||
status,
|
||||
label,
|
||||
labelTx,
|
||||
labelTxOptions,
|
||||
LabelTextProps,
|
||||
labelPosition,
|
||||
labelStyle: $labelStyleOverride,
|
||||
} = props
|
||||
|
||||
if (!label && !labelTx && !LabelTextProps?.children) return null
|
||||
|
||||
const $labelStyle = [
|
||||
$label,
|
||||
status === "error" && { color: colors.error },
|
||||
labelPosition === "right" && $labelRight,
|
||||
labelPosition === "left" && $labelLeft,
|
||||
$labelStyleOverride,
|
||||
LabelTextProps?.style,
|
||||
]
|
||||
|
||||
return (
|
||||
<Text
|
||||
preset="formLabel"
|
||||
text={label}
|
||||
tx={labelTx}
|
||||
txOptions={labelTxOptions}
|
||||
{...LabelTextProps}
|
||||
style={$labelStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const $inputWrapper: ViewStyle = {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}
|
||||
|
||||
const $inputOuterBase: ViewStyle = {
|
||||
height: 24,
|
||||
width: 24,
|
||||
borderWidth: 2,
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
justifyContent: "space-between",
|
||||
flexDirection: "row",
|
||||
}
|
||||
|
||||
const $inputOuterVariants: Record<Variants, StyleProp<ViewStyle>> = {
|
||||
checkbox: [$inputOuterBase, { borderRadius: 4 }],
|
||||
radio: [$inputOuterBase, { borderRadius: 12 }],
|
||||
switch: [$inputOuterBase, { height: 32, width: 56, borderRadius: 16, borderWidth: 0 }],
|
||||
}
|
||||
|
||||
const $checkboxInner: ViewStyle = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
}
|
||||
|
||||
const $checkboxDetail: ImageStyle = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
resizeMode: "contain",
|
||||
}
|
||||
|
||||
const $radioInner: ViewStyle = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
}
|
||||
|
||||
const $radioDetail: ViewStyle = {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
}
|
||||
|
||||
const $switchInner: ViewStyle = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
borderColor: colors.transparent,
|
||||
overflow: "hidden",
|
||||
position: "absolute",
|
||||
paddingStart: 4,
|
||||
paddingEnd: 4,
|
||||
}
|
||||
|
||||
const $switchDetail: SwitchToggleProps["inputDetailStyle"] = {
|
||||
borderRadius: 12,
|
||||
position: "absolute",
|
||||
width: 24,
|
||||
height: 24,
|
||||
}
|
||||
|
||||
const $helper: TextStyle = {
|
||||
marginTop: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $label: TextStyle = {
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $labelRight: TextStyle = {
|
||||
marginStart: spacing.medium,
|
||||
}
|
||||
|
||||
const $labelLeft: TextStyle = {
|
||||
marginEnd: spacing.medium,
|
||||
}
|
||||
|
||||
const $switchAccessibility: TextStyle = {
|
||||
width: "40%",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}
|
||||
|
||||
const $switchAccessibilityIcon: ImageStyle = {
|
||||
width: 14,
|
||||
height: 14,
|
||||
resizeMode: "contain",
|
||||
}
|
||||
|
||||
const $switchAccessibilityLine: ViewStyle = {
|
||||
width: 2,
|
||||
height: 12,
|
||||
}
|
||||
|
||||
const $switchAccessibilityCircle: ViewStyle = {
|
||||
borderWidth: 2,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from "./AutoImage"
|
||||
export * from "./Button"
|
||||
export * from "./Card"
|
||||
export * from "./Header"
|
||||
export * from "./Icon"
|
||||
export * from "./ListItem"
|
||||
export * from "./Screen"
|
||||
export * from "./Text"
|
||||
export * from "./TextField"
|
||||
export * from "./Toggle"
|
||||
export * from "./EmptyState"
|
||||
export * from "./ErrorModal"
|
||||
export * from "./InfoModal"
|
||||
export * from "./BottomModal"
|
||||
export * from "./Loading"
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface ConfigBaseProps {
|
||||
persistNavigation: "always" | "dev" | "prod" | "never"
|
||||
catchErrors: "always" | "dev" | "prod" | "never"
|
||||
exitRoutes: string[]
|
||||
}
|
||||
|
||||
export type PersistNavigationConfig = ConfigBaseProps["persistNavigation"]
|
||||
|
||||
const BaseConfig: ConfigBaseProps = {
|
||||
// This feature is particularly useful in development mode, but
|
||||
// can be used in production as well if you prefer.
|
||||
persistNavigation: "dev",
|
||||
|
||||
/**
|
||||
* Only enable if we're catching errors in the right environment
|
||||
*/
|
||||
catchErrors: "always",
|
||||
|
||||
/**
|
||||
* This is a list of all the route names that will exit the app if the back button
|
||||
* is pressed while in that screen. Only affects Android.
|
||||
*/
|
||||
exitRoutes: ["Welcome"],
|
||||
}
|
||||
|
||||
export default BaseConfig
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* These are configuration settings for the dev environment.
|
||||
*
|
||||
* Do not include API secrets in this file or anywhere in your JS.
|
||||
*
|
||||
* https://reactnative.dev/docs/security#storing-sensitive-info
|
||||
*/
|
||||
export default {
|
||||
API_URL: "https://api.rss2json.com/v1/",
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* These are configuration settings for the production environment.
|
||||
*
|
||||
* Do not include API secrets in this file or anywhere in your JS.
|
||||
*
|
||||
* https://reactnative.dev/docs/security#storing-sensitive-info
|
||||
*/
|
||||
export default {
|
||||
API_URL: "CHANGEME",
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* This file imports configuration objects from either the config.dev.js file
|
||||
* or the config.prod.js file depending on whether we are in __DEV__ or not.
|
||||
*
|
||||
* Note that we do not gitignore these files. Unlike on web servers, just because
|
||||
* these are not checked into your repo doesn't mean that they are secure.
|
||||
* In fact, you're shipping a JavaScript bundle with every
|
||||
* config variable in plain text. Anyone who downloads your app can easily
|
||||
* extract them.
|
||||
*
|
||||
* If you doubt this, just bundle your app, and then go look at the bundle and
|
||||
* search it for one of your config variable values. You'll find it there.
|
||||
*
|
||||
* Read more here: https://reactnative.dev/docs/security#storing-sensitive-info
|
||||
*/
|
||||
import BaseConfig from "./config.base"
|
||||
import ProdConfig from "./config.prod"
|
||||
import DevConfig from "./config.dev"
|
||||
|
||||
let ExtraConfig = ProdConfig
|
||||
|
||||
if (__DEV__) {
|
||||
ExtraConfig = DevConfig
|
||||
}
|
||||
|
||||
const Config = { ...BaseConfig, ...ExtraConfig }
|
||||
|
||||
export default Config
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
const en = {
|
||||
common: {
|
||||
ok: "OK",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
back: "Back",
|
||||
paste: "Paste",
|
||||
scan: "Scan",
|
||||
copy: "Copy",
|
||||
},
|
||||
welcomeScreen: {
|
||||
heading: "Welcome to Minibits",
|
||||
intro: "Minibits is an e-cash wallet with a focus on performance and usability for non-techies. Cash is backed by Bitcoin via the Cashu protocol and Lightning Network.",
|
||||
warning1: "This wallet should be used for research purposes only.",
|
||||
warning2: "The wallet is an alpha version with incomplete functionality and both known and unknown bugs.",
|
||||
warning3: "Do not use it with large amounts of coins.",
|
||||
warning4: "Start by connecting to the mint you trust or by receiving coins from another e-cash Cashu wallet.",
|
||||
warning5: "Visit github.com/minibits-cash to give your feedback.",
|
||||
go: "Let's go!"
|
||||
},
|
||||
walletScreen: {
|
||||
fund: "Fund wallet",
|
||||
withdraw: "Withdraw",
|
||||
send: "Send",
|
||||
receive: "Receive",
|
||||
topUpWallet: "Top up wallet",
|
||||
topUpWalletSubText: "Top up your balance by paying Bitcoin lightning invoice from another wallet",
|
||||
transferFromWallet: "Transfer from wallet",
|
||||
transferFromWalletSubText: "Debit your balance by paying Bitcoin lightning invoice from this wallet",
|
||||
},
|
||||
receiveScreen: {
|
||||
title: "Receive",
|
||||
paste: "Paste coin",
|
||||
receive: "Receive",
|
||||
newMintsAdded: {
|
||||
one: "{{count}} mint has been added to your wallet",
|
||||
other: "{{count}} mints have been added to your wallet",
|
||||
},
|
||||
toReceive: "Amount to receive",
|
||||
received: "Amount received:",
|
||||
sharePaymentRequest: "Share payment request",
|
||||
sharePaymentRequestDescription: "Share the amount and contact information as a link or QR code, so that the payer can pay you the requested amount.",
|
||||
scanQRCodeToReceive: "Scan QR code to receive",
|
||||
scanQRCodeToReceiveDescription: "If you see the coins in QR code format on another device, scan it to receive.",
|
||||
pasteFromClipboard: "Paste from clipboard",
|
||||
pasteFromClipboardDescription: "If you've received your coins through any other app, paste them here to receive."
|
||||
},
|
||||
transferScreen: {
|
||||
pasteLightningInvoice: "Paste lightning invoice",
|
||||
pasteLightningInvoiceDescription: "Paste the Bitcoin lightning invoice you want to pay. The mint will pay this invoice on your behalf in exchange for the coins from your wallet.",
|
||||
scanLightningInvoice: "Scan lightning invoice",
|
||||
scanLightningInvoiceDescription: "Scan the Bitcoin lightning invoice in QR code format. You can use your coins to pay any service supporting lightning payments."
|
||||
},
|
||||
topupScreen: {
|
||||
sendInvoiceToContact: "Send invoice to contact",
|
||||
sendInvoiceToContactDescription: "Send a lightning invoice to one of your contacts so they can top up your balance by paying it.",
|
||||
showInvoiceQRCode: "Show invoice QR code",
|
||||
showInvoiceQRCodeDescription: "Present the lightning invoice as a QR code so that you can pay it from the wallet on another device.",
|
||||
shareInvoiceAsText: "Share invoice as text",
|
||||
shareInvoiceAsTextDescription: "Copy and share the invoice with anybody through a secure app of your choice."
|
||||
},
|
||||
sendScreen: {
|
||||
sendToContact: "Send to contact",
|
||||
sendToContactDescription: "Send your coins to one of your contacts stored in your contact list.",
|
||||
showAsQRCode: "Show as QR code",
|
||||
showAsQRCodeDescription: "Present the coins as a QR code so that the recipient can scan it to receive them.",
|
||||
shareAsText: "Share as text",
|
||||
shareAsTextDescription: "Copy and send the coins to anybody through a secure app of your choice."
|
||||
},
|
||||
tranDetailScreen: {
|
||||
amount: "Amount (in satoshis)",
|
||||
memoFromSender: "Memo from sender",
|
||||
sentFrom: "Sent from",
|
||||
type: "Type",
|
||||
status: "Status",
|
||||
balanceAfter: "Balance after this transaction",
|
||||
createdAt: "Created at",
|
||||
id: "ID",
|
||||
memoToReceiver: "Memo to receiver",
|
||||
revert: "Revert",
|
||||
claim: "Claim",
|
||||
memoFromInvoice: "Memo from invoice",
|
||||
paidFrom: "Paid from",
|
||||
receivedTo: "Received to",
|
||||
},
|
||||
settingsScreen: {
|
||||
title: "Settings",
|
||||
manageMints: "Manage mints",
|
||||
mintsCount: {
|
||||
one: "{{count}} mint",
|
||||
other: "{{count}} mints",
|
||||
},
|
||||
go: "Let's go!",
|
||||
backupRecovery: "Backup and recovery",
|
||||
security: "Security",
|
||||
devOptions: "Developer options",
|
||||
},
|
||||
mintsScreen: {
|
||||
addMintUrl: "Add mint URL",
|
||||
mintUrl: "Mint URL",
|
||||
mintUrlHint: "Provide the URL of a trusted mint",
|
||||
invalidUrl: "This is not a valid URL",
|
||||
mintExists: "This mint already exists",
|
||||
addMint: "Add mint",
|
||||
mintAdded: "The mint has been added",
|
||||
mintInfo: "Mint information page",
|
||||
rename: "Rename mint",
|
||||
setColor: "Change the mint's icon color",
|
||||
copy: "Copy mint URL",
|
||||
removeMint: "Remove mint",
|
||||
blockMint: "Block receiving from this mint",
|
||||
unblockMint: "Unblock receiving from this mint",
|
||||
mintRemoved: "The mint has been removed",
|
||||
mintBlocked: "The mint has been blocked. The app will not receive coins from this mint.",
|
||||
mintUnblocked: "The mint has been unblocked. You can now receive coins from this mint."
|
||||
},
|
||||
backupScreen: {
|
||||
localBackup: "Local backup",
|
||||
localBackupDescription: "Local backup stores a copy of all your coins in the local database. Coins are never deleted unless you switch off the backup. Please note that this backup is not encrypted.",
|
||||
recoveryTool: "Recovery tool",
|
||||
recoveryToolDescription: "Show coins backed up in the local database and attempt to recover unspent ones into the wallet in case the wallet storage gets corrupted or a transaction fails due to an unexpected error.",
|
||||
removeSpentCoins: "Remove spent coins",
|
||||
removeSpentCoinsDescription: "In case of a SEND or TRANSFER transaction error, spent coins may remain in your wallet. This blocks further transactions with the mint, making the wallet unusable. This tool removes spent coins from the wallet."
|
||||
},
|
||||
securityScreen: {
|
||||
encryptStorage: "Encrypt storage",
|
||||
encryptStorageDescription: "Encrypt the storage that stores your coins with the secret key generated on your device and stored in secure keys storage. On some Android devices, this may cause a slightly longer startup of the application.",
|
||||
},
|
||||
developerScreen: {
|
||||
title: "Developer options",
|
||||
transactions: "Sync recent transactions",
|
||||
transactionsDescription: "There are {{count}} recent transactions cached in the app's fast storage. Resetting will reload them from the app's database.",
|
||||
reset: "Factory reset",
|
||||
resetDescription: "This will wipe out all local data from application state and from the local database including any stored coins. As of now, there is no way to recover your coins. Do only during development or testing.",
|
||||
go: "Let's go!",
|
||||
devOptions: "Developer options",
|
||||
},
|
||||
errorScreen: {
|
||||
title: "Something went wrong!",
|
||||
friendlySubtitle:
|
||||
"Unfortunately, the Minibits app encountered an unexpected error.",
|
||||
reset: "RESET APP",
|
||||
},
|
||||
tabNavigator: {
|
||||
walletLabel: "Wallet",
|
||||
contactsLabel: "Contacts",
|
||||
settingsLabel: "Settings",
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
export type Translations = typeof en;
|
||||
@@ -0,0 +1,44 @@
|
||||
// import * as Localization from "expo-localization"
|
||||
import { I18n } from "i18n-js"
|
||||
import { I18nManager } from "react-native"
|
||||
|
||||
// if English isn't your default language, move Translations to the appropriate language file.
|
||||
import en, { Translations } from "./en"
|
||||
|
||||
|
||||
|
||||
export const i18n = new I18n({'en-US': en})
|
||||
|
||||
// i18n.fallbacks = true
|
||||
/**
|
||||
* we need always include "*-US" for some valid language codes because when you change the system language,
|
||||
* the language code is the suffixed with "-US". i.e. if a device is set to English ("en"),
|
||||
* if you change to another language and then return to English language code is now "en-US".
|
||||
*/
|
||||
i18n.translations = { en, "en-US": en }
|
||||
|
||||
// TODO get current system locale
|
||||
i18n.locale = "en-US"
|
||||
|
||||
/**
|
||||
* Builds up valid keypaths for translations.
|
||||
*/
|
||||
export type TxKeyPath = RecursiveKeyOf<Translations>
|
||||
|
||||
// via: https://stackoverflow.com/a/65333050
|
||||
type RecursiveKeyOf<TObj extends object> = {
|
||||
[TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<TObj[TKey], `${TKey}`>
|
||||
}[keyof TObj & (string | number)]
|
||||
|
||||
type RecursiveKeyOfInner<TObj extends object> = {
|
||||
[TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<
|
||||
TObj[TKey],
|
||||
`['${TKey}']` | `.${TKey}`
|
||||
>
|
||||
}[keyof TObj & (string | number)]
|
||||
|
||||
type RecursiveKeyOfHandleValue<TValue, Text extends string> = TValue extends any[]
|
||||
? Text
|
||||
: TValue extends object
|
||||
? Text | `${Text}${RecursiveKeyOfInner<TValue>}`
|
||||
: Text
|
||||
@@ -0,0 +1,3 @@
|
||||
import "./i18n"
|
||||
export * from "./i18n"
|
||||
export * from "./translate"
|
||||
@@ -0,0 +1,32 @@
|
||||
|
||||
import { I18nOptions } from "i18n-js"
|
||||
import { i18n } from "./i18n"
|
||||
import { TxKeyPath } from "./i18n"
|
||||
|
||||
/**
|
||||
* Translates text.
|
||||
*
|
||||
* @param key The i18n key.
|
||||
* @param options The i18n options.
|
||||
* @returns The translated text.
|
||||
*
|
||||
* @example
|
||||
* Translations:
|
||||
*
|
||||
* ```en.ts
|
||||
* {
|
||||
* "hello": "Hello, {{name}}!"
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { translate } from "i18n-js"
|
||||
*
|
||||
* translate("common.ok", { name: "world" })
|
||||
* // => "Hello world!"
|
||||
* ```
|
||||
*/
|
||||
export function translate(key: TxKeyPath, options?: I18nOptions) {
|
||||
return i18n.t(key, options)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
|
||||
import {decodeInvoice} from '../services/cashuHelpers'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import {log} from '../utils/logger'
|
||||
import addSeconds from 'date-fns/addSeconds'
|
||||
|
||||
/**
|
||||
* This represents lightning invoice got for request to mint new tokens
|
||||
*/
|
||||
|
||||
export const InvoiceModel = types
|
||||
.model('Invoice', {
|
||||
mint: types.string,
|
||||
encodedInvoice: types.string,
|
||||
amount: types.number,
|
||||
description: types.optional(types.string, ''),
|
||||
paymentHash: types.string,
|
||||
expiry: types.optional(types.number, 600),
|
||||
memo: types.optional(types.string, 'Topup of minibits wallet'),
|
||||
transactionId: types.number,
|
||||
expiresAt: types.maybe(types.Date),
|
||||
createdAt: types.optional(types.Date, new Date()),
|
||||
})
|
||||
.actions(withSetPropAction)
|
||||
.actions(self => ({
|
||||
setExpiresAt() {
|
||||
const decoded = decodeInvoice(self.encodedInvoice)
|
||||
let {expiry} = decoded // in seconds
|
||||
if (!expiry) {
|
||||
expiry = 600
|
||||
}
|
||||
const expiresAt = addSeconds(new Date(), expiry)
|
||||
|
||||
log.trace(
|
||||
`Invoice expiry is ${expiry}, setting expiresAt to ${expiresAt}`,
|
||||
)
|
||||
self.expiresAt = new Date(expiresAt)
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
export type Invoice = {
|
||||
mint: string
|
||||
encodedInvoice: string
|
||||
amount: number
|
||||
description?: string
|
||||
paymentHash: string
|
||||
expiry?: number
|
||||
memo: string
|
||||
transactionId: number
|
||||
} & Partial<Instance<typeof InvoiceModel>>
|
||||
export interface InvoiceSnapshotOut extends SnapshotOut<typeof InvoiceModel> {}
|
||||
export interface InvoiceSnapshotIn extends SnapshotIn<typeof InvoiceModel> {}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Instance,
|
||||
SnapshotOut,
|
||||
types,
|
||||
destroy,
|
||||
isStateTreeNode,
|
||||
} from 'mobx-state-tree'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import {InvoiceModel, Invoice} from './Invoice'
|
||||
import {log} from '../utils/logger'
|
||||
|
||||
export const InvoicesStoreModel = types
|
||||
.model('Invoices', {
|
||||
invoices: types.array(InvoiceModel),
|
||||
})
|
||||
.views(self => ({
|
||||
findByPaymentHash(paymentHash: string) {
|
||||
const invoice = self.invoices.find(i => i.paymentHash === paymentHash)
|
||||
return invoice ? invoice : undefined
|
||||
},
|
||||
}))
|
||||
.actions(withSetPropAction)
|
||||
.actions(self => ({
|
||||
addInvoice(newInvoice: Invoice) {
|
||||
const invoiceInstance = InvoiceModel.create(newInvoice)
|
||||
// expiry in Date format
|
||||
invoiceInstance.setExpiresAt()
|
||||
self.invoices.push(invoiceInstance)
|
||||
|
||||
log.info('New invoice added to InvoicesStore')
|
||||
|
||||
return invoiceInstance
|
||||
},
|
||||
removeInvoice(invoiceToRemove: Invoice) {
|
||||
// self.invoices.remove(invoice)
|
||||
|
||||
let invoiceInstance: Invoice | undefined
|
||||
|
||||
if (isStateTreeNode(invoiceToRemove)) {
|
||||
invoiceInstance = invoiceToRemove
|
||||
} else {
|
||||
invoiceInstance = self.findByPaymentHash(
|
||||
(invoiceToRemove as Invoice).paymentHash,
|
||||
)
|
||||
}
|
||||
|
||||
if (invoiceInstance) {
|
||||
destroy(invoiceInstance)
|
||||
log.info('Invoice removed from InvoicesStore')
|
||||
}
|
||||
},
|
||||
}))
|
||||
.views(self => ({
|
||||
get invoicesCount() {
|
||||
return self.invoices.length
|
||||
},
|
||||
get allInvoices() {
|
||||
return self.invoices
|
||||
},
|
||||
filterByMint(mintUrl: string) {
|
||||
let filtered: Invoice[] = []
|
||||
|
||||
filtered = self.invoices.filter(invoice => {
|
||||
if (invoice.mint === mintUrl) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return filtered
|
||||
},
|
||||
}))
|
||||
|
||||
export interface Invoices extends Instance<typeof InvoicesStoreModel> {}
|
||||
export interface InvoicesStoreSnapshot
|
||||
extends SnapshotOut<typeof InvoicesStoreModel> {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import type {MintKeys} from '@cashu/cashu-ts/dist/lib/es5/model/types'
|
||||
import {colors, getRandomIconColor} from '../theme'
|
||||
|
||||
// used as a helper type across app
|
||||
export type MintBalance = {
|
||||
mint: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
/**
|
||||
* This represents a Cashu mint
|
||||
*/
|
||||
export const MintModel = types
|
||||
.model('Mint', {
|
||||
mintUrl: types.identifier,
|
||||
hostname: types.maybe(types.string),
|
||||
shortname: types.maybe(types.string),
|
||||
keys: types.frozen<MintKeys>(),
|
||||
keysets: types.frozen<string[]>(),
|
||||
color: types.optional(types.string, colors.palette.iconBlue200),
|
||||
createdAt: types.optional(types.Date, new Date()),
|
||||
})
|
||||
.actions(withSetPropAction)
|
||||
.actions(self => ({
|
||||
validateURL(url: string) {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
setHostname() {
|
||||
try {
|
||||
self.hostname = new URL(self.mintUrl).hostname
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
setShortname(shortname: string) {
|
||||
try {
|
||||
self.shortname = shortname.slice(0, 25)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
setRandomColor() {
|
||||
self.color = getRandomIconColor()
|
||||
},
|
||||
setColor(color: string) {
|
||||
self.color = color
|
||||
},
|
||||
}))
|
||||
|
||||
export type Mint = {
|
||||
mintUrl: string
|
||||
keys: MintKeys
|
||||
keysets: string[]
|
||||
} & Partial<Instance<typeof MintModel>>
|
||||
export interface MintSnapshotOut extends SnapshotOut<typeof MintModel> {}
|
||||
export interface MintSnapshotIn extends SnapshotIn<typeof MintModel> {}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Instance,
|
||||
SnapshotOut,
|
||||
types,
|
||||
destroy,
|
||||
isStateTreeNode,
|
||||
} from 'mobx-state-tree'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import {MintModel, Mint} from './Mint'
|
||||
import {log} from '../utils/logger'
|
||||
|
||||
export type MintsByHostname = {
|
||||
hostname: string
|
||||
mints: Mint[]
|
||||
}
|
||||
|
||||
export const MintsStoreModel = types
|
||||
.model('MintsStore', {
|
||||
mints: types.array(MintModel),
|
||||
blockedMintUrls: types.array(types.string),
|
||||
})
|
||||
.views(self => ({
|
||||
findByUrl: (mintUrl: string | URL) => {
|
||||
const mint = self.mints.find(m => m.mintUrl === mintUrl)
|
||||
return mint ? mint : undefined
|
||||
},
|
||||
}))
|
||||
.actions(withSetPropAction)
|
||||
.actions(self => ({
|
||||
addMint(newMint: Mint) {
|
||||
const mintInstance = MintModel.create(newMint)
|
||||
// set derived properties
|
||||
mintInstance.setHostname()
|
||||
const lastSlashIndex = newMint.mintUrl.lastIndexOf('/')
|
||||
mintInstance.setShortname(newMint.mintUrl.substring(lastSlashIndex + 1))
|
||||
mintInstance.setRandomColor()
|
||||
|
||||
self.mints.push(mintInstance)
|
||||
|
||||
log.info('New mint added to the MintsStore')
|
||||
},
|
||||
removeMint(mintToBeRemoved: Mint) {
|
||||
if (self.blockedMintUrls.some(m => m === mintToBeRemoved.mintUrl)) {
|
||||
self.blockedMintUrls.remove(mintToBeRemoved.mintUrl)
|
||||
log.info('Mint removed from blockedMintUrls')
|
||||
}
|
||||
|
||||
let mintInstance: Mint | undefined
|
||||
|
||||
if (isStateTreeNode(mintToBeRemoved)) {
|
||||
mintInstance = mintToBeRemoved
|
||||
} else {
|
||||
mintInstance = self.findByUrl((mintToBeRemoved as Mint).mintUrl)
|
||||
}
|
||||
|
||||
if (mintInstance) {
|
||||
destroy(mintInstance)
|
||||
log.info('Mint removed from MintsStore')
|
||||
}
|
||||
},
|
||||
blockMint(mintToBeBlocked: Mint) {
|
||||
self.blockedMintUrls.push(mintToBeBlocked.mintUrl)
|
||||
log.info('Mint blocked in MintsStore')
|
||||
},
|
||||
unblockMint(blockedMint: Mint) {
|
||||
self.blockedMintUrls.remove(blockedMint.mintUrl)
|
||||
log.info('Mint unblocked in MintsStore')
|
||||
},
|
||||
}))
|
||||
.views(self => ({
|
||||
get mintCount() {
|
||||
return self.mints.length
|
||||
},
|
||||
get allMints() {
|
||||
return self.mints
|
||||
},
|
||||
get groupedByHostname() {
|
||||
const grouped: Record<string, MintsByHostname> = {}
|
||||
|
||||
self.mints.forEach((mint: Mint) => {
|
||||
const {hostname} = mint
|
||||
|
||||
if (!grouped[hostname]) {
|
||||
grouped[hostname] = {
|
||||
hostname,
|
||||
mints: [],
|
||||
}
|
||||
}
|
||||
|
||||
grouped[hostname].mints.push(mint)
|
||||
})
|
||||
|
||||
return Object.values(grouped) as MintsByHostname[]
|
||||
},
|
||||
alreadyExists(mintUrl: string) {
|
||||
return self.mints.some(m => m.mintUrl === mintUrl) ? true : false
|
||||
},
|
||||
isBlocked(mintUrl: string) {
|
||||
return self.blockedMintUrls.some(m => m === mintUrl) ? true : false
|
||||
},
|
||||
getBlockedFromList(mintUrls: string[]) {
|
||||
return mintUrls.filter(mintUrl =>
|
||||
self.blockedMintUrls.some(blockedUrl => blockedUrl === mintUrl),
|
||||
)
|
||||
},
|
||||
getMissingMints: (mintUrls: string[]) => {
|
||||
const missingMints: string[] = []
|
||||
for (const url of mintUrls) {
|
||||
if (!self.mints.find(mint => mint.mintUrl === url)) {
|
||||
missingMints.push(url)
|
||||
}
|
||||
}
|
||||
return missingMints
|
||||
},
|
||||
}))
|
||||
|
||||
export interface MintsStore extends Instance<typeof MintsStoreModel> {}
|
||||
export interface MintsStoreSnapshot
|
||||
extends SnapshotOut<typeof MintsStoreModel> {}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
|
||||
/**
|
||||
* Proof type saved in database backup
|
||||
*/
|
||||
|
||||
export type BackupProof = Proof & {
|
||||
isPending: boolean
|
||||
isSpent: boolean
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Proof model
|
||||
*/
|
||||
|
||||
export const ProofModel = types
|
||||
.model('Proof', {
|
||||
id: types.string,
|
||||
amount: types.number,
|
||||
secret: types.identifier,
|
||||
C: types.string,
|
||||
tId: types.maybe(types.number),
|
||||
mintUrl: types.maybe(types.string),
|
||||
})
|
||||
.actions(withSetPropAction)
|
||||
.actions(self => ({
|
||||
setTransactionId(id: number) {
|
||||
self.tId = id
|
||||
},
|
||||
setMintUrl(url: string) {
|
||||
self.mintUrl = url
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
export interface Proof extends Instance<typeof ProofModel> {}
|
||||
export interface ProofSnapshotOut extends SnapshotOut<typeof ProofModel> {}
|
||||
export interface ProofSnapshotIn extends SnapshotIn<typeof ProofModel> {}
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
Instance,
|
||||
SnapshotOut,
|
||||
types,
|
||||
isStateTreeNode,
|
||||
detach,
|
||||
} from 'mobx-state-tree'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import {ProofModel, Proof} from './Proof'
|
||||
import {log} from '../utils/logger'
|
||||
import {getRootStore} from './helpers/getRootStore'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {Mint, MintBalance} from './Mint'
|
||||
import {Database} from '../services'
|
||||
|
||||
export const ProofsStoreModel = types
|
||||
.model('Proofs', {
|
||||
proofs: types.array(ProofModel),
|
||||
pendingProofs: types.array(ProofModel),
|
||||
})
|
||||
.actions(withSetPropAction)
|
||||
.views(self => ({
|
||||
getBySecret(secret: string, isPending: boolean = false): Proof | undefined {
|
||||
const proofs = isPending ? self.pendingProofs : self.proofs
|
||||
return proofs.find(proof => proof.secret === secret) || undefined
|
||||
},
|
||||
}))
|
||||
.views(self => ({
|
||||
getMintFromProof(proof: Proof): Mint | undefined {
|
||||
const rootStore = getRootStore(self)
|
||||
const {mintsStore} = rootStore
|
||||
|
||||
for (const m of mintsStore.allMints) {
|
||||
if (m.keysets?.includes(proof.id)) {
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
getByMint(
|
||||
mintUrl: string,
|
||||
isPending: boolean = false,
|
||||
): Proof[] | undefined {
|
||||
const proofs = isPending ? self.pendingProofs : self.proofs
|
||||
return proofs.filter(proof => proof.mintUrl === mintUrl)
|
||||
},
|
||||
getProofInstance(proof: Proof, isPending: boolean = false) {
|
||||
let proofInstance: Proof | undefined
|
||||
if (isStateTreeNode(proof)) {
|
||||
proofInstance = proof
|
||||
} else {
|
||||
proofInstance = self.getBySecret((proof as Proof).secret, isPending)
|
||||
}
|
||||
|
||||
return proofInstance
|
||||
},
|
||||
|
||||
}))
|
||||
.actions(self => ({
|
||||
addProofs(newProofs: Proof[], isPending: boolean = false) {
|
||||
try {
|
||||
const proofs = isPending ? self.pendingProofs : self.proofs
|
||||
|
||||
for (const proof of newProofs) {
|
||||
if (isStateTreeNode(proof)) {
|
||||
proofs.push(proof)
|
||||
} else {
|
||||
const proofInstance = ProofModel.create(proof)
|
||||
proofs.push(proofInstance)
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Added new ${newProofs.length}${isPending ? ' pending' : ''} proofs to ProofsStore`,
|
||||
)
|
||||
|
||||
const rootStore = getRootStore(self)
|
||||
const {userSettingsStore} = rootStore
|
||||
|
||||
if (userSettingsStore.isLocalBackupOn === true) {
|
||||
Database.addOrUpdateProofs(newProofs, isPending)
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.STORAGE_ERROR, e.message)
|
||||
}
|
||||
},
|
||||
removeProofs(proofsToRemove: Proof[], isPending: boolean = false) {
|
||||
try {
|
||||
const proofs = isPending ? self.pendingProofs : self.proofs
|
||||
|
||||
const rootStore = getRootStore(self)
|
||||
const count = proofsToRemove.length
|
||||
const {userSettingsStore} = rootStore
|
||||
|
||||
if (userSettingsStore.isLocalBackupOn === true) {
|
||||
// isPending = false, isSpent = true
|
||||
Database.addOrUpdateProofs(proofsToRemove, false, true)
|
||||
}
|
||||
|
||||
proofsToRemove.map(proof => detach(proof)) // vital
|
||||
|
||||
proofs.replace(proofs.filter(proof => !proofsToRemove.includes(proof)))
|
||||
|
||||
log.info(`${count} ${(isPending) ? 'pending' : ''} proofs removed from ProofsStore`)
|
||||
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.STORAGE_ERROR, e.message)
|
||||
}
|
||||
},
|
||||
}))
|
||||
.views(self => ({
|
||||
get proofsCount() {
|
||||
return self.proofs.length
|
||||
},
|
||||
get allProofs() {
|
||||
return self.proofs
|
||||
},
|
||||
get allPendingProofs() {
|
||||
return self.pendingProofs
|
||||
},
|
||||
}))
|
||||
.views(self => ({
|
||||
getBalances() {
|
||||
let totalBalance = 0
|
||||
let totalPendingBalance = 0
|
||||
|
||||
const mints = getRootStore(self).mintsStore.allMints
|
||||
const mintBalances: MintBalance[] = mints.map(mint => {
|
||||
return {mint: mint.mintUrl, balance: 0}
|
||||
})
|
||||
const mintPendingBalances: MintBalance[] = mints.map(mint => {
|
||||
return {mint: mint.mintUrl, balance: 0}
|
||||
})
|
||||
|
||||
self.proofs.forEach(proof => {
|
||||
const amount = proof.amount
|
||||
totalBalance += amount
|
||||
|
||||
for (const mintBalance of mintBalances) {
|
||||
if (mintBalance.mint === proof.mintUrl) {
|
||||
mintBalance.balance += amount
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.pendingProofs.forEach(proof => {
|
||||
const amount = proof.amount
|
||||
totalPendingBalance += amount
|
||||
|
||||
for (const pendingBalance of mintPendingBalances) {
|
||||
if (pendingBalance.mint === proof.mintUrl) {
|
||||
pendingBalance.balance += amount
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const balances = {
|
||||
totalBalance,
|
||||
totalPendingBalance,
|
||||
mintBalances,
|
||||
mintPendingBalances,
|
||||
}
|
||||
|
||||
log.trace(balances)
|
||||
|
||||
return balances
|
||||
},
|
||||
}))
|
||||
.views(self => ({
|
||||
getMintBalancesWithEnoughBalance: (amount: number) => {
|
||||
const balances = self.getBalances().mintBalances
|
||||
|
||||
const filteredMintBalances = balances
|
||||
.slice()
|
||||
.filter((balance: MintBalance) => balance.balance >= amount)
|
||||
.sort((a, b) => b.balance - a.balance)
|
||||
|
||||
return filteredMintBalances
|
||||
},
|
||||
getProofsToSend: (amount: number, proofs: Proof[]) => {
|
||||
let proofsAmount = 0
|
||||
const proofSubset = proofs.filter(proof => {
|
||||
if (proofsAmount < amount) {
|
||||
proofsAmount += proof.amount
|
||||
return true
|
||||
}
|
||||
})
|
||||
return proofSubset
|
||||
},
|
||||
getProofsSubset: (proofs: Proof[], proofsToRemove: Proof[]) => {
|
||||
return proofs.filter(proof => !proofsToRemove.includes(proof))
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
export interface Proofs extends Instance<typeof ProofsStoreModel> {}
|
||||
export interface ProofsStoreSnapshot
|
||||
extends SnapshotOut<typeof ProofsStoreModel> {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {Instance, SnapshotOut, types} from 'mobx-state-tree'
|
||||
import {MintsStoreModel} from './MintsStore'
|
||||
import {TransactionsStoreModel} from './TransactionsStore'
|
||||
import {UserSettingsStoreModel} from './UserSettingsStore'
|
||||
import {InvoicesStoreModel} from './InvoicesStore'
|
||||
import {ProofsStoreModel} from './ProofsStore'
|
||||
|
||||
export const rootStoreModelVersion = 1 // Update this if model changes require migrations defined in setupRootStore.ts
|
||||
|
||||
/**
|
||||
* A RootStore model.
|
||||
*/
|
||||
export const RootStoreModel = types
|
||||
.model('RootStore')
|
||||
.props({
|
||||
userSettingsStore: types.optional(UserSettingsStoreModel, {}),
|
||||
mintsStore: types.optional(MintsStoreModel, {}),
|
||||
proofsStore: types.optional(ProofsStoreModel, {}),
|
||||
transactionsStore: types.optional(TransactionsStoreModel, {}),
|
||||
invoicesStore: types.optional(InvoicesStoreModel, {}),
|
||||
version: types.optional(types.number, rootStoreModelVersion),
|
||||
})
|
||||
.actions(self => ({
|
||||
setVersion(version: number) {
|
||||
self.version = version
|
||||
},
|
||||
}))
|
||||
|
||||
/**
|
||||
* The RootStore instance.
|
||||
*/
|
||||
export interface RootStore extends Instance<typeof RootStoreModel> {}
|
||||
/**
|
||||
* The data of a RootStore.
|
||||
*/
|
||||
export interface RootStoreSnapshot extends SnapshotOut<typeof RootStoreModel> {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {Instance, types} from 'mobx-state-tree'
|
||||
import {TokenEntryModel} from './TokenEntry'
|
||||
|
||||
/**
|
||||
* This represents sendable Cashu token V3
|
||||
*/
|
||||
|
||||
export const TokenModel = types.model('Token', {
|
||||
token: types.array(TokenEntryModel),
|
||||
memo: types.maybe(types.string),
|
||||
})
|
||||
|
||||
export interface Token extends Instance<typeof TokenModel> {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import {ProofModel} from './Proof'
|
||||
// import type { Proof } from '@cashu/cashu-ts'
|
||||
|
||||
/**
|
||||
* This represents a Cashu token V3
|
||||
*/
|
||||
|
||||
export const TokenEntryModel = types
|
||||
.model('TokenEntry', {
|
||||
mint: types.string,
|
||||
proofs: types.array(
|
||||
types.safeReference(ProofModel, {acceptsUndefined: false}),
|
||||
),
|
||||
transactionId: types.maybe(types.number),
|
||||
})
|
||||
|
||||
|
||||
export interface TokenEntry extends Instance<typeof TokenEntryModel> {}
|
||||
export interface TokenEntrySnapshotOut
|
||||
extends SnapshotOut<typeof TokenEntryModel> {}
|
||||
export interface TokenEntrySnapshotIn
|
||||
extends SnapshotIn<typeof TokenEntryModel> {}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
|
||||
|
||||
export interface TransactionRecord {
|
||||
id: number
|
||||
type: TransactionType
|
||||
amount: number
|
||||
data: string
|
||||
sentFrom?: string
|
||||
memo?: string
|
||||
balanceAfter?: number
|
||||
noteToSelf?: string
|
||||
tags?: Array<string>
|
||||
status: TransactionStatus
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type TransactionData = {
|
||||
status: TransactionStatus
|
||||
[index: string]: any
|
||||
}
|
||||
|
||||
export enum TransactionType {
|
||||
SEND = 'SEND',
|
||||
RECEIVE = 'RECEIVE',
|
||||
RECEIVE_NOSTR = 'RECEIVE_NOSTR',
|
||||
TOPUP = 'TOPUP',
|
||||
TRANSFER = 'TRANSFER',
|
||||
}
|
||||
|
||||
export enum TransactionStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
PREPARED = 'PREPARED',
|
||||
PENDING = 'PENDING',
|
||||
REVERTED = 'REVERTED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
ERROR = 'ERROR',
|
||||
BLOCKED = 'BLOCKED',
|
||||
EXPIRED = 'EXPIRED',
|
||||
}
|
||||
|
||||
export const TransactionModel = types
|
||||
.model('Transaction', {
|
||||
id: types.maybe(types.number),
|
||||
type: types.frozen<TransactionType>(),
|
||||
amount: types.integer,
|
||||
fee: types.maybe(types.maybeNull(types.integer)),
|
||||
data: types.string,
|
||||
sentFrom: types.maybe(types.maybeNull(types.string)),
|
||||
memo: types.maybe(types.maybeNull(types.string)),
|
||||
balanceAfter: types.maybe(types.maybeNull(types.integer)),
|
||||
noteToSelf: types.maybe(types.maybeNull(types.string)),
|
||||
tags: types.maybe(types.maybeNull(types.array(types.string))),
|
||||
status: types.frozen<TransactionStatus>(),
|
||||
createdAt: types.optional(types.Date, new Date()),
|
||||
})
|
||||
|
||||
export type Transaction = {
|
||||
amount: number
|
||||
type: TransactionType
|
||||
data: string
|
||||
status: TransactionStatus
|
||||
} & Partial<Instance<typeof TransactionModel>>
|
||||
export interface TransactionSnapshotOut
|
||||
extends SnapshotOut<typeof TransactionModel> {}
|
||||
export interface TransactionSnapshotIn
|
||||
extends SnapshotIn<typeof TransactionModel> {}
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Instance,
|
||||
SnapshotOut,
|
||||
types,
|
||||
flow,
|
||||
destroy,
|
||||
isStateTreeNode,
|
||||
} from 'mobx-state-tree'
|
||||
import {withSetPropAction} from './helpers/withSetPropAction'
|
||||
import {
|
||||
TransactionModel,
|
||||
Transaction,
|
||||
TransactionStatus,
|
||||
TransactionRecord,
|
||||
} from './Transaction'
|
||||
import {Database} from '../services'
|
||||
import {log} from '../utils/logger'
|
||||
|
||||
export const maxTransactionsInModel = 10
|
||||
|
||||
export const TransactionsStoreModel = types
|
||||
.model('TransactionsStore', {
|
||||
transactions: types.array(TransactionModel),
|
||||
})
|
||||
.actions(withSetPropAction)
|
||||
.actions(self => ({
|
||||
findById: (id: number) => {
|
||||
const tx = self.transactions.find(tx => tx.id === id)
|
||||
return tx ? tx : undefined
|
||||
},
|
||||
removeTransaction: (removedTransaction: Transaction) => {
|
||||
let transactionInstance: Transaction | undefined
|
||||
|
||||
if (isStateTreeNode(removedTransaction)) {
|
||||
transactionInstance = removedTransaction
|
||||
} else {
|
||||
transactionInstance = self.transactions.find(
|
||||
(t: Transaction) => t.id === (removedTransaction as Transaction).id,
|
||||
)
|
||||
}
|
||||
|
||||
if (transactionInstance) {
|
||||
destroy(transactionInstance)
|
||||
log.info('Transaction removed from TransactionsStore')
|
||||
}
|
||||
},
|
||||
removeOldTransactions: () => {
|
||||
const numTransactions = self.transactions.length
|
||||
|
||||
// If there are more than 10 transactions, delete the older ones
|
||||
if (numTransactions > maxTransactionsInModel) {
|
||||
self.transactions
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) // Sort transactions by createdAt in descending order
|
||||
.splice(maxTransactionsInModel) // Remove transactions beyond the desired number to keep
|
||||
|
||||
log.info(
|
||||
`${
|
||||
numTransactions - maxTransactionsInModel
|
||||
} transaction(s) removed from TransactionsStore`,
|
||||
)
|
||||
}
|
||||
},
|
||||
}))
|
||||
.actions(self => ({
|
||||
addTransaction: flow(function* addTransaction(newTransaction: Transaction) {
|
||||
// First let's store the transaction into the database
|
||||
const dbTransaction = yield Database.addTransactionAsync(newTransaction)
|
||||
|
||||
// Add the new transaction to the transactions store
|
||||
const createdAt = new Date(dbTransaction.createdAt)
|
||||
const inStoreTransaction = {...dbTransaction, createdAt}
|
||||
|
||||
const transactionInstance = TransactionModel.create(inStoreTransaction)
|
||||
self.transactions.push(transactionInstance)
|
||||
|
||||
log.info(
|
||||
'New transaction added to the TransactionsStore',
|
||||
[],
|
||||
'addTransaction',
|
||||
)
|
||||
|
||||
// Purge the oldest transaction from the model if the maximum number of transactions is reached
|
||||
if (self.transactions.length > maxTransactionsInModel) {
|
||||
self.removeOldTransactions()
|
||||
}
|
||||
|
||||
return transactionInstance
|
||||
}),
|
||||
addTransactionsToModel: (dbTransactions: TransactionRecord[]) => {
|
||||
// This adds to model only. Used to have observable UI in tx history loaded from database.
|
||||
const inStoreTransactions: Transaction[] = []
|
||||
|
||||
for (const dbTransaction of dbTransactions) {
|
||||
const createdAt = new Date(dbTransaction.createdAt)
|
||||
const inStoreTransaction = {...dbTransaction, createdAt}
|
||||
|
||||
const transactionInstance = TransactionModel.create(inStoreTransaction)
|
||||
inStoreTransactions.push(transactionInstance)
|
||||
}
|
||||
|
||||
self.transactions.push(...inStoreTransactions)
|
||||
|
||||
log.trace(
|
||||
`${inStoreTransactions.length} new transactions added to TransactionsStore`,
|
||||
)
|
||||
},
|
||||
updateStatus: flow(function* updateStatus(
|
||||
id: number,
|
||||
status: TransactionStatus,
|
||||
data: string,
|
||||
) {
|
||||
// Update status and set related tx data in database
|
||||
yield Database.updateStatusAsync(id, status, data)
|
||||
const transactionInstance = self.findById(id)
|
||||
|
||||
// Update in the model
|
||||
if (transactionInstance) {
|
||||
transactionInstance.status = status
|
||||
transactionInstance.data = data
|
||||
log.info(
|
||||
'Transaction status and data updated in TransactionsStore',
|
||||
'updateStatus',
|
||||
)
|
||||
}
|
||||
|
||||
return transactionInstance
|
||||
}),
|
||||
updateStatuses: flow(function* updateStatuses(
|
||||
ids: number[],
|
||||
status: TransactionStatus,
|
||||
data: string,
|
||||
) {
|
||||
// Update status and amend to existing data in database
|
||||
yield Database.updateStatusesAsync(ids, status, data)
|
||||
|
||||
// Update the model status and amend related tx data
|
||||
for (const id of ids) {
|
||||
const transactionInstance = self.findById(id)
|
||||
|
||||
if (transactionInstance) {
|
||||
transactionInstance.status = status
|
||||
|
||||
// Awkward but I want to keep function signature aligned with single status update
|
||||
const updatedData = JSON.parse(transactionInstance.data)
|
||||
updatedData.push(JSON.parse(data))
|
||||
transactionInstance.data = JSON.stringify(updatedData)
|
||||
|
||||
|
||||
log.info('Transaction status and data updated in TransactionsStore',[],'updateStatuses',)
|
||||
}
|
||||
}
|
||||
}),
|
||||
updateBalanceAfter: flow(function* updateBalanceAfter(
|
||||
id: number,
|
||||
balanceAfter: number,
|
||||
) {
|
||||
// Update status and related metadata in database
|
||||
yield Database.updateBalanceAfterAsync(id, balanceAfter)
|
||||
|
||||
const transactionInstance = self.findById(id)
|
||||
|
||||
// Update in the model
|
||||
if (transactionInstance) {
|
||||
transactionInstance.balanceAfter = balanceAfter
|
||||
log.info('Transaction balanceAfter updated in TransactionsStore',[],'updateBalanceAfter',)
|
||||
}
|
||||
|
||||
return transactionInstance
|
||||
}),
|
||||
updateFee: flow(function* updateFee(id: number, fee: number) {
|
||||
// Update status and related metadata in database
|
||||
yield Database.updateBalanceAfterAsync(id, fee)
|
||||
|
||||
const transactionInstance = self.findById(id)
|
||||
|
||||
// Update in the model
|
||||
if (transactionInstance) {
|
||||
transactionInstance.fee = fee
|
||||
log.info('Transaction fee updated in TransactionsStore')
|
||||
}
|
||||
|
||||
return transactionInstance
|
||||
}),
|
||||
updateReceivedAmount: flow(function* updateReceivedAmount(
|
||||
id: number,
|
||||
amount: number,
|
||||
) {
|
||||
// Update status and related metadata in database
|
||||
yield Database.updateReceivedAmountAsync(id, amount)
|
||||
|
||||
const transactionInstance = self.findById(id)
|
||||
|
||||
// Update in the model
|
||||
if (transactionInstance) {
|
||||
transactionInstance.amount = amount
|
||||
log.info('Transaction amount updated in TransactionsStore')
|
||||
}
|
||||
|
||||
return transactionInstance
|
||||
}),
|
||||
saveNote: flow(function* saveNote(id: number, note: string) {
|
||||
// update note stored in database
|
||||
yield Database.updateNoteAsync(id, note)
|
||||
|
||||
// check if the tx is in recent transactions stored in this store
|
||||
const transactionInstance = self.findById(id)
|
||||
// if we found it, we update the model so that the UI updates
|
||||
// TODO how to handle missing UI updates for older notes that are stored in db only
|
||||
if (transactionInstance) {
|
||||
transactionInstance.noteToSelf = note
|
||||
log.info('Transaction note updated in TransactionsStore')
|
||||
}
|
||||
}),
|
||||
removeAllTransactions() {
|
||||
for (const t of self.transactions){
|
||||
destroy(t)
|
||||
}
|
||||
|
||||
log.info('Removed all transactions from TransactionsStore')
|
||||
},
|
||||
}))
|
||||
.views(self => ({
|
||||
get count() {
|
||||
return self.transactions.length
|
||||
},
|
||||
get recent(): Transaction[] {
|
||||
return this.all.slice(0, 3) // Return the first 5 transactions
|
||||
},
|
||||
get all() {
|
||||
return self.transactions
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
// Sort by createdAt timestamp
|
||||
if (a.createdAt && b.createdAt) {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
}
|
||||
})
|
||||
},
|
||||
get pending() {
|
||||
return this.all.filter(t => t.status === TransactionStatus.PENDING)
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
export interface TransactionsStore
|
||||
extends Instance<typeof TransactionsStoreModel> {}
|
||||
export interface TransactionsStoreSnapshot
|
||||
extends SnapshotOut<typeof TransactionsStoreModel> {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree'
|
||||
import {Database} from '../services'
|
||||
import {MMKVStorage} from '../services'
|
||||
import {log} from '../utils/logger'
|
||||
|
||||
export type UserSettings = {
|
||||
id?: number
|
||||
isOnboarded: boolean | 0 | 1
|
||||
isStorageEncrypted: boolean | 0 | 1
|
||||
isLocalBackupOn: boolean | 0 | 1
|
||||
}
|
||||
|
||||
export const UserSettingsStoreModel = types
|
||||
.model('UserSettingsStore')
|
||||
.props({
|
||||
isOnboarded: types.optional(types.boolean, false),
|
||||
isStorageEncrypted: types.optional(types.boolean, false),
|
||||
isLocalBackupOn: types.optional(types.boolean, true),
|
||||
})
|
||||
.actions(self => ({
|
||||
loadUserSettings: () => {
|
||||
const {isOnboarded, isStorageEncrypted, isLocalBackupOn} =
|
||||
Database.getUserSettings()
|
||||
// TODO move to some of mobx preprocessing method
|
||||
self.isOnboarded = isOnboarded as boolean
|
||||
self.isStorageEncrypted = isStorageEncrypted as boolean
|
||||
self.isLocalBackupOn = isLocalBackupOn as boolean
|
||||
},
|
||||
setIsOnboarded: (value: boolean) => {
|
||||
Database.updateUserSettings({...self, isOnboarded: value})
|
||||
self.isOnboarded = value
|
||||
|
||||
log.info('Onboarded new device', value)
|
||||
},
|
||||
setIsLocalBackupOn: (value: boolean) => {
|
||||
Database.updateUserSettings({...self, isLocalBackupOn: value})
|
||||
self.isLocalBackupOn = value
|
||||
|
||||
log.info('Local backup is turned on', value)
|
||||
return value
|
||||
},
|
||||
setIsStorageEncrypted: flow(function* setIsStorageEncryptedvalue(
|
||||
value: boolean,
|
||||
) {
|
||||
const isEncrypted = yield MMKVStorage.recryptStorage()
|
||||
Database.updateUserSettings({...self, isStorageEncrypted: isEncrypted})
|
||||
self.isStorageEncrypted = isEncrypted
|
||||
|
||||
log.info('Storage is encrypted', value)
|
||||
return isEncrypted
|
||||
}),
|
||||
}))
|
||||
.views(self => ({
|
||||
get isUserOnboarded() {
|
||||
return self.isOnboarded
|
||||
},
|
||||
get isAppStorageEncrypted() {
|
||||
return self.isStorageEncrypted
|
||||
},
|
||||
get userSettings() {
|
||||
return self
|
||||
},
|
||||
}))
|
||||
/*.preProcessSnapshot((snapshot) => {
|
||||
// remove sensitive data from snapshot to avoid secrets
|
||||
// being stored in AsyncStorage in plain text if backing up store
|
||||
// const { authToken, authPassword, ...rest } = snapshot // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
// see the following for strategies to consider storing secrets on device
|
||||
// https://reactnative.dev/docs/security#storing-sensitive-info
|
||||
|
||||
// return rest
|
||||
return snapshot
|
||||
})*/
|
||||
|
||||
export interface UserSettingsStore
|
||||
extends Instance<typeof UserSettingsStoreModel> {}
|
||||
export interface UserSettingsStoreSnapshot
|
||||
extends SnapshotOut<typeof UserSettingsStoreModel> {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getRoot, IStateTreeNode } from "mobx-state-tree"
|
||||
import { RootStore, RootStoreModel } from "../RootStore"
|
||||
|
||||
/**
|
||||
* Returns a RootStore object in strongly typed way
|
||||
* for stores to access other stores.
|
||||
*/
|
||||
export const getRootStore = (self: IStateTreeNode): RootStore => {
|
||||
return getRoot<typeof RootStoreModel>(self)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* This file is where we do "rehydration" of your RootStore from AsyncStorage.
|
||||
* This lets you persist your state between app launches.
|
||||
*
|
||||
* Navigation state persistence is handled in navigationUtilities.tsx.
|
||||
*
|
||||
* Note that Fast Refresh doesn't play well with this file, so if you edit this,
|
||||
* do a full refresh of your app instead.
|
||||
*
|
||||
* @refresh reset
|
||||
*/
|
||||
import {
|
||||
applySnapshot,
|
||||
IDisposer,
|
||||
IModelType,
|
||||
ModelInstanceType,
|
||||
onSnapshot,
|
||||
} from 'mobx-state-tree'
|
||||
import type {RootStore} from '../RootStore'
|
||||
import {MMKVStorage} from '../../services'
|
||||
import {Database} from '../../services'
|
||||
import { log } from '../../utils/logger'
|
||||
import { rootStoreModelVersion } from '../RootStore'
|
||||
import AppError, { Err } from '../../utils/AppError'
|
||||
// import { AsyncStorage } from "../../services"
|
||||
|
||||
/**
|
||||
* The key we'll be saving our state as within storage.
|
||||
*/
|
||||
|
||||
const ROOT_STORAGE_KEY = 'minibits-root-storage'
|
||||
|
||||
/**
|
||||
* Setup the root state.
|
||||
*/
|
||||
let _disposer: IDisposer
|
||||
export async function setupRootStore(rootStore: RootStore) {
|
||||
let restoredState: any
|
||||
// let latestSnapshot: any
|
||||
|
||||
try {
|
||||
// Give an option to encrypt storage as it might slow down app start on some Android devices
|
||||
// User settings are mastered in sqlite so we can get the encryption setting before loading root store
|
||||
const userSettings = Database.getUserSettings()
|
||||
|
||||
if (userSettings.isStorageEncrypted) {
|
||||
await MMKVStorage.initEncryption() // key retrieval on Android is sometimes very slow
|
||||
}
|
||||
|
||||
// load the last known state from storage
|
||||
restoredState = MMKVStorage.load(ROOT_STORAGE_KEY) || {}
|
||||
applySnapshot(rootStore, restoredState)
|
||||
|
||||
} catch (e: any) {
|
||||
log.error(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
|
||||
// stop tracking state changes if we've already setup
|
||||
if (_disposer) {
|
||||
_disposer()
|
||||
}
|
||||
|
||||
// track changes & save to storage // TODO defering and batching of writes to storage
|
||||
_disposer = onSnapshot(rootStore, snapshot =>
|
||||
MMKVStorage.save(ROOT_STORAGE_KEY, snapshot),
|
||||
)
|
||||
|
||||
// run migrations if needed, needs to be after onSnapshot to be persisted
|
||||
try {
|
||||
log.info('Device rootStorage version', rootStore.version, 'setupRootStore')
|
||||
|
||||
if(rootStore.version < rootStoreModelVersion) {
|
||||
await _runMigrations(rootStore)
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
|
||||
const unsubscribe = () => {
|
||||
_disposer()
|
||||
_disposer = undefined
|
||||
}
|
||||
|
||||
return {rootStore, restoredState, unsubscribe}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrations code to execute based on code and on device model version.
|
||||
*/
|
||||
|
||||
async function _runMigrations(rootStore: RootStore) {
|
||||
const {
|
||||
mintsStore,
|
||||
} = rootStore
|
||||
|
||||
let currentVersion = rootStore.version
|
||||
try {
|
||||
// v1 -> v2 migration
|
||||
if(currentVersion < 2) {
|
||||
log.trace(`Starting rootStore migrations from version ${currentVersion} -> v2`)
|
||||
|
||||
currentVersion = 2
|
||||
log.info(`Completed rootStore migrations to version ${currentVersion}`)
|
||||
}
|
||||
|
||||
// v2 -> v3 migration
|
||||
if(currentVersion < 3) {
|
||||
log.trace(`Starting rootStore migrations from ${currentVersion} -> v3`)
|
||||
|
||||
currentVersion = 3
|
||||
log.trace(`Completed rootStore migrations to version ${currentVersion}`)
|
||||
}
|
||||
|
||||
rootStore.setVersion(rootStoreModelVersion)
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Error when executing rootStore migrations',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* interface StoreConfig { storageKey: string, store: any }
|
||||
|
||||
let _disposers: IDisposer[] = []
|
||||
export async function setupRootStore(rootStore: RootStore) {
|
||||
|
||||
|
||||
const storeConfigs: StoreConfig[] = [
|
||||
{ storageKey: "mintsStore", store: rootStore.mintsStore },
|
||||
|
||||
{ storageKey: "transactionsStore", store: rootStore.transactionsStore },
|
||||
]
|
||||
|
||||
const restoredStates: { [key: string]: any } = {}
|
||||
|
||||
try {
|
||||
// retrieve key asynchronously
|
||||
const encryptionKey = await MMKVStorage.getOrCreateMMKVEncryptionKey()
|
||||
|
||||
// initialize or reuse storage with encryption key so we can work in synchronous mode
|
||||
MMKVStorage.getInstance(encryptionKey)
|
||||
|
||||
for (const { storageKey, store } of storeConfigs) {
|
||||
const restoredState = (MMKVStorage.load(storageKey)) || {}
|
||||
applySnapshot(store, restoredState)
|
||||
restoredStates[storageKey] = restoredState
|
||||
}
|
||||
} catch (e: any) {
|
||||
// if there's any problems loading, then inform the dev what happened
|
||||
if (__DEV__) {
|
||||
console.error(e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
// stop tracking state changes if we've already setup
|
||||
_disposers.forEach(disposer => disposer)
|
||||
_disposers = []
|
||||
|
||||
// track changes & save to AsyncStorage for each store
|
||||
for (const { storageKey, store } of storeConfigs) {
|
||||
const disposer = onSnapshot(store, (snapshot) => MMKVStorage.save(storageKey, snapshot))
|
||||
_disposers.push(disposer)
|
||||
}
|
||||
|
||||
const unsubscribe = () => {
|
||||
_disposers.forEach(disposer => disposer())
|
||||
_disposers = []
|
||||
}
|
||||
|
||||
return { rootStore, restoredStates, unsubscribe }
|
||||
} */
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
// import { setReactotronRootStore } from "../../services/reactotron"
|
||||
import { RootStore, RootStoreModel } from "../RootStore"
|
||||
import { setupRootStore } from "./setupRootStore"
|
||||
|
||||
/**
|
||||
* Create the initial (empty) global RootStore instance here.
|
||||
*
|
||||
* Later, it will be rehydrated in app.tsx with the setupRootStore function.
|
||||
*
|
||||
* If your RootStore requires specific properties to be instantiated,
|
||||
* you can do so here.
|
||||
*
|
||||
* If your RootStore has a _ton_ of sub-stores and properties (the tree is
|
||||
* very large), you may want to use a different strategy than immediately
|
||||
* instantiating it, although that should be rare.
|
||||
*/
|
||||
const _rootStore = RootStoreModel.create({})
|
||||
|
||||
/**
|
||||
* The RootStoreContext provides a way to access
|
||||
* the RootStore in any screen or component.
|
||||
*/
|
||||
const RootStoreContext = createContext<RootStore>(_rootStore)
|
||||
|
||||
/**
|
||||
* You can use this Provider to specify a *different* RootStore
|
||||
* than the singleton version above if you need to. Generally speaking,
|
||||
* this Provider & custom RootStore instances would only be used in
|
||||
* testing scenarios.
|
||||
*/
|
||||
export const RootStoreProvider = RootStoreContext.Provider
|
||||
|
||||
/**
|
||||
* A hook that screens and other components can use to gain access to
|
||||
* our stores:
|
||||
*
|
||||
* const rootStore = useStores()
|
||||
*
|
||||
* or:
|
||||
*
|
||||
* const { someStore, someOtherStore } = useStores()
|
||||
*/
|
||||
export const useStores = () => useContext(RootStoreContext)
|
||||
|
||||
/**
|
||||
* Used only in the app.tsx file, this hook sets up the RootStore
|
||||
* and then rehydrates it. It connects everything with Reactotron
|
||||
* and then lets the app know that everything is ready to go.
|
||||
*/
|
||||
export const useInitialRootStore = (callback: () => void | Promise<void>) => {
|
||||
const rootStore = useStores()
|
||||
const [rehydrated, setRehydrated] = useState(false)
|
||||
|
||||
// Kick off initial async loading actions, like loading fonts and rehydrating RootStore
|
||||
useEffect(() => {
|
||||
let _unsubscribe
|
||||
;(async () => {
|
||||
// set up the RootStore (returns the state restored from storage)
|
||||
const { restoredState, unsubscribe } = await setupRootStore(rootStore)
|
||||
_unsubscribe = unsubscribe
|
||||
|
||||
// reactotron integration with the MST root store (DEV only)
|
||||
// setReactotronRootStore(rootStore, restoredState)
|
||||
|
||||
// let the app know we've finished rehydrating
|
||||
setRehydrated(true)
|
||||
|
||||
// invoke the callback, if provided
|
||||
if (callback) callback()
|
||||
})()
|
||||
|
||||
return () => {
|
||||
// cleanup
|
||||
if (_unsubscribe) _unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { rootStore, rehydrated }
|
||||
}
|
||||
|
||||
// Access the same root store instace outside of React context
|
||||
export const rootStoreInstance = _rootStore
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IStateTreeNode, SnapshotIn } from "mobx-state-tree"
|
||||
|
||||
/**
|
||||
* If you include this in your model in an action() block just under your props,
|
||||
* it'll allow you to set property values directly while retaining type safety
|
||||
* and also is executed in an action. This is useful because often you find yourself
|
||||
* making a lot of repetitive setter actions that only update one prop.
|
||||
*
|
||||
* E.g.:
|
||||
*
|
||||
* const UserModel = types.model("User")
|
||||
* .props({
|
||||
* name: types.string,
|
||||
* age: types.number
|
||||
* })
|
||||
* .actions(withSetPropAction)
|
||||
*
|
||||
* const user = UserModel.create({ name: "Jamon", age: 40 })
|
||||
*
|
||||
* user.setProp("name", "John") // no type error
|
||||
* user.setProp("age", 30) // no type error
|
||||
* user.setProp("age", "30") // type error -- must be number
|
||||
*/
|
||||
export const withSetPropAction = <T extends IStateTreeNode>(mstInstance: T) => ({
|
||||
// generic setter for all properties
|
||||
setProp<K extends keyof SnapshotIn<T>, V extends SnapshotIn<T>[K]>(field: K, newValue: V) {
|
||||
// @ts-ignore - for some reason TS complains about this, but it still works fine
|
||||
mstInstance[field] = newValue
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./RootStore"
|
||||
export * from "./helpers/getRootStore"
|
||||
export * from "./helpers/useStores"
|
||||
export * from "./helpers/setupRootStore"
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The app navigator (formerly "AppNavigator" and "MainNavigator") is used for the primary
|
||||
* navigation flows of your app.
|
||||
* Generally speaking, it will contain an auth flow (registration, login, forgot password)
|
||||
* and a "main" flow which the user will use once logged in.
|
||||
*/
|
||||
import {
|
||||
NavigationContainer,
|
||||
NavigatorScreenParams
|
||||
} from "@react-navigation/native"
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack"
|
||||
import { StackScreenProps } from "@react-navigation/stack"
|
||||
import { observer } from "mobx-react-lite"
|
||||
import React from "react"
|
||||
import Config from "../config"
|
||||
import {
|
||||
WelcomeScreen,
|
||||
} from "../screens"
|
||||
import { useStores } from "../models"
|
||||
import { TabsNavigator, TabsParamList } from "./TabsNavigator"
|
||||
import { navigationRef, useBackButtonHandler } from "./navigationUtilities"
|
||||
|
||||
/**
|
||||
* This type allows TypeScript to know what routes are defined in this navigator
|
||||
* as well as what properties (if any) they might take when navigating to them.
|
||||
*
|
||||
* If no params are allowed, pass through `undefined`. Generally speaking, we
|
||||
* recommend using your MobX-State-Tree store(s) to keep application state
|
||||
* rather than passing state through navigation params.
|
||||
*
|
||||
* For more information, see this documentation:
|
||||
* https://reactnavigation.org/docs/params/
|
||||
* https://reactnavigation.org/docs/typescript#type-checking-the-navigator
|
||||
* https://reactnavigation.org/docs/typescript/#organizing-types
|
||||
*/
|
||||
export type AppStackParamList = {
|
||||
Welcome: undefined
|
||||
Tabs: NavigatorScreenParams<TabsParamList>
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a list of all the route names that will exit the app if the back button
|
||||
* is pressed while in that screen. Only affects Android.
|
||||
*/
|
||||
const exitRoutes = Config.exitRoutes
|
||||
|
||||
export type AppStackScreenProps<T extends keyof AppStackParamList> = StackScreenProps<
|
||||
AppStackParamList,
|
||||
T
|
||||
>
|
||||
|
||||
// Documentation: https://reactnavigation.org/docs/stack-navigator/
|
||||
const Stack = createNativeStackNavigator<AppStackParamList>()
|
||||
|
||||
|
||||
const AppStack = observer(function AppStack() {
|
||||
|
||||
const { userSettingsStore } = useStores()
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false, // managed with hook + custom component
|
||||
}}
|
||||
>
|
||||
{userSettingsStore.isUserOnboarded ? (
|
||||
<Stack.Screen name="Tabs" component={TabsNavigator} />
|
||||
) : (
|
||||
<>
|
||||
<Stack.Screen name="Welcome" component={WelcomeScreen} />
|
||||
<Stack.Screen name="Tabs" component={TabsNavigator}/>
|
||||
</>
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
)
|
||||
})
|
||||
|
||||
interface NavigationProps extends Partial<React.ComponentProps<typeof NavigationContainer>> {}
|
||||
|
||||
export const AppNavigator = observer(function AppNavigator(props: NavigationProps) {
|
||||
|
||||
useBackButtonHandler((routeName) => exitRoutes.includes(routeName))
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
// theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
|
||||
{...props}
|
||||
>
|
||||
<AppStack />
|
||||
</NavigationContainer>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { BottomTabScreenProps, createBottomTabNavigator } from "@react-navigation/bottom-tabs"
|
||||
import { CompositeScreenProps, DarkTheme, DefaultTheme, NavigationContainer, NavigatorScreenParams } from "@react-navigation/native"
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack"
|
||||
import { StackScreenProps } from "@react-navigation/stack"
|
||||
import React from "react"
|
||||
import { TextStyle, ViewStyle } from "react-native"
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||
import { Icon } from "../components"
|
||||
import { translate } from "../i18n"
|
||||
import {
|
||||
WalletScreen,
|
||||
ReceiveScreen,
|
||||
SendScreen,
|
||||
ScanScreen,
|
||||
ContactsScreen,
|
||||
SettingsScreen,
|
||||
MintsScreen,
|
||||
DeveloperScreen,
|
||||
SecurityScreen,
|
||||
BackupScreen,
|
||||
LocalRecoveryScreen,
|
||||
TranDetailScreen,
|
||||
TranHistoryScreen,
|
||||
TransferScreen,
|
||||
TopupScreen,
|
||||
} from "../screens"
|
||||
import { colors, useThemeColor, spacing, typography } from "../theme"
|
||||
import { AppStackParamList, AppStackScreenProps } from "./AppNavigator"
|
||||
|
||||
|
||||
export type TabsParamList = {
|
||||
WalletNavigator: NavigatorScreenParams<WalletStackParamList>
|
||||
Contacts: undefined
|
||||
SettingsNavigator: NavigatorScreenParams<SettingsStackParamList>
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for automatically generating navigation prop types for each route.
|
||||
*
|
||||
* More info: https://reactnavigation.org/docs/typescript/#organizing-types
|
||||
*/
|
||||
export type TabScreenProps<T extends keyof TabsParamList> = CompositeScreenProps<
|
||||
BottomTabScreenProps<TabsParamList, T>,
|
||||
AppStackScreenProps<keyof AppStackParamList>
|
||||
>
|
||||
|
||||
const Tab = createBottomTabNavigator<TabsParamList>()
|
||||
|
||||
export function TabsNavigator() {
|
||||
const { bottom } = useSafeAreaInsets()
|
||||
const bgColor = useThemeColor('menu')
|
||||
const textColor = useThemeColor('text')
|
||||
const activeColor = useThemeColor('tint')
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
initialRouteName="WalletNavigator"
|
||||
screenOptions={{
|
||||
headerShown: false, // managed with hook + custom component
|
||||
tabBarHideOnKeyboard: true,
|
||||
tabBarStyle: [$tabBar, { height: bottom + 70, backgroundColor: bgColor }],
|
||||
tabBarActiveTintColor: activeColor as string,
|
||||
tabBarInactiveTintColor: textColor as string,
|
||||
tabBarLabelStyle: [$tabBarLabel, {color: textColor}],
|
||||
tabBarItemStyle: $tabBarItem,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="WalletNavigator"
|
||||
component={WalletNavigator}
|
||||
options={{
|
||||
tabBarLabel: translate("tabNavigator.walletLabel"),
|
||||
tabBarIcon: ({ focused }) => <Icon icon="faWallet" color={focused ? activeColor : textColor} size={spacing.large} />,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="Contacts"
|
||||
component={ContactsScreen}
|
||||
options={{
|
||||
tabBarLabel: translate("tabNavigator.contactsLabel"),
|
||||
tabBarIcon: ({ focused }) => <Icon icon="faAddressBook" color={focused ? activeColor : textColor} size={spacing.large} />,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="SettingsNavigator"
|
||||
component={SettingsNavigator}
|
||||
options={{
|
||||
tabBarLabel: translate("tabNavigator.settingsLabel"),
|
||||
tabBarIcon: ({ focused }) => <Icon icon="faSliders" color={focused ? activeColor : textColor} size={spacing.large} />,
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export type WalletStackParamList = {
|
||||
Wallet: undefined
|
||||
Receive: {scannedEncodedToken? : string}
|
||||
Send: undefined
|
||||
Scan: undefined
|
||||
TranDetail: {id: number}
|
||||
TranHistory: undefined
|
||||
Transfer: {scannedEncodedInvoice? : string}
|
||||
Topup: undefined
|
||||
}
|
||||
|
||||
export type WalletStackScreenProps<T extends keyof WalletStackParamList> = StackScreenProps<
|
||||
WalletStackParamList,
|
||||
T
|
||||
>
|
||||
|
||||
const WalletStack = createNativeStackNavigator<WalletStackParamList>()
|
||||
|
||||
const WalletNavigator = function WalletNavigator() {
|
||||
|
||||
return (
|
||||
<WalletStack.Navigator
|
||||
screenOptions={{
|
||||
presentation: 'transparentModal', // prevents white glitch on scren change in dark mode
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<WalletStack.Screen name="Wallet" component={WalletScreen} />
|
||||
<WalletStack.Screen name="Receive" component={ReceiveScreen} />
|
||||
<WalletStack.Screen name="Send" component={SendScreen} />
|
||||
<WalletStack.Screen name="Scan" component={ScanScreen} />
|
||||
<WalletStack.Screen name="TranDetail" component={TranDetailScreen} />
|
||||
<WalletStack.Screen name="TranHistory" component={TranHistoryScreen} />
|
||||
<WalletStack.Screen name="Transfer" component={TransferScreen} />
|
||||
<WalletStack.Screen name="Topup" component={TopupScreen} />
|
||||
</WalletStack.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export type SettingsStackParamList = {
|
||||
Settings: undefined
|
||||
Mints: undefined
|
||||
Security: undefined
|
||||
Backup: undefined
|
||||
LocalRecovery: undefined
|
||||
Developer: undefined
|
||||
}
|
||||
|
||||
export type SettingsStackScreenProps<T extends keyof SettingsStackParamList> = StackScreenProps<
|
||||
SettingsStackParamList,
|
||||
T
|
||||
>
|
||||
|
||||
const SettingsStack = createNativeStackNavigator<SettingsStackParamList>()
|
||||
|
||||
const SettingsNavigator = function SettingsNavigator() {
|
||||
|
||||
return (
|
||||
<SettingsStack.Navigator
|
||||
screenOptions={{
|
||||
presentation: 'transparentModal', // prevents white glitch on scren change in dark mode
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<SettingsStack.Screen name="Settings" component={SettingsScreen} />
|
||||
<SettingsStack.Screen name="Mints" component={MintsScreen} />
|
||||
<SettingsStack.Screen name="Security" component={SecurityScreen} />
|
||||
<SettingsStack.Screen name="Backup" component={BackupScreen} />
|
||||
<SettingsStack.Screen name="LocalRecovery" component={LocalRecoveryScreen} />
|
||||
<SettingsStack.Screen name="Developer" component={DeveloperScreen} />
|
||||
</SettingsStack.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
const $tabBar: ViewStyle = {
|
||||
borderTopColor: 'transparent',
|
||||
borderTopWidth: 0,
|
||||
elevation: 0
|
||||
}
|
||||
|
||||
const $tabBarItem: ViewStyle = {
|
||||
paddingTop: spacing.medium,
|
||||
}
|
||||
|
||||
const $tabBarLabel: TextStyle = {
|
||||
fontSize: 12,
|
||||
fontFamily: typography.primary?.medium,
|
||||
lineHeight: 16,
|
||||
flex: 1,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./AppNavigator"
|
||||
export * from "./TabsNavigator"
|
||||
export * from "./navigationUtilities"
|
||||
// export other navigators from here
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { BackHandler, Platform } from "react-native"
|
||||
import {
|
||||
PartialState,
|
||||
NavigationState,
|
||||
NavigationAction,
|
||||
createNavigationContainerRef,
|
||||
} from "@react-navigation/native"
|
||||
import Config from "../config"
|
||||
import type { PersistNavigationConfig } from "../config/config.base"
|
||||
// import { useIsMounted } from "../utils/useIsMounted"
|
||||
|
||||
/* eslint-disable */
|
||||
export const RootNavigation = {
|
||||
navigate(_name: string, _params?: any) {},
|
||||
goBack() {},
|
||||
resetRoot(_state?: PartialState<NavigationState> | NavigationState) {},
|
||||
getRootState(): NavigationState {
|
||||
return {} as any
|
||||
},
|
||||
dispatch(_action: NavigationAction) {},
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
export const navigationRef = createNavigationContainerRef()
|
||||
|
||||
/**
|
||||
* Gets the current screen from any navigation state.
|
||||
*/
|
||||
export function getActiveRouteName(state: NavigationState | PartialState<NavigationState>) {
|
||||
const route = state.routes[state.index]
|
||||
|
||||
// Found the active route -- return the name
|
||||
if (!route.state) return route.name
|
||||
|
||||
// Recursive call to deal with nested routers
|
||||
return getActiveRouteName(route.state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that handles Android back button presses and forwards those on to
|
||||
* the navigation or allows exiting the app.
|
||||
*/
|
||||
export function useBackButtonHandler(canExit: (routeName: string) => boolean) {
|
||||
// ignore if iOS ... no back button!
|
||||
if (Platform.OS === "ios") return
|
||||
|
||||
// The reason we're using a ref here is because we need to be able
|
||||
// to update the canExit function without re-setting up all the listeners
|
||||
const canExitRef = useRef(canExit)
|
||||
|
||||
useEffect(() => {
|
||||
canExitRef.current = canExit
|
||||
}, [canExit])
|
||||
|
||||
useEffect(() => {
|
||||
// We'll fire this when the back button is pressed on Android.
|
||||
const onBackPress = () => {
|
||||
if (!navigationRef.isReady()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// grab the current route
|
||||
const routeName = getActiveRouteName(navigationRef.getRootState())
|
||||
|
||||
// are we allowed to exit?
|
||||
if (canExitRef.current(routeName)) {
|
||||
// exit and let the system know we've handled the event
|
||||
BackHandler.exitApp()
|
||||
return true
|
||||
}
|
||||
|
||||
// we can't exit, so let's turn this into a back action
|
||||
if (navigationRef.canGoBack()) {
|
||||
navigationRef.goBack()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Subscribe when we come to life
|
||||
BackHandler.addEventListener("hardwareBackPress", onBackPress)
|
||||
|
||||
// Unsubscribe when we're done
|
||||
return () => BackHandler.removeEventListener("hardwareBackPress", onBackPress)
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper function will determine whether we should enable navigation persistence
|
||||
* based on a config setting and the __DEV__ environment (dev or prod).
|
||||
*/
|
||||
function navigationRestoredDefaultState(persistNavigation: PersistNavigationConfig) {
|
||||
if (persistNavigation === "always") return false
|
||||
if (persistNavigation === "dev" && __DEV__) return false
|
||||
if (persistNavigation === "prod" && !__DEV__) return false
|
||||
|
||||
// all other cases, disable restoration by returning true
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for persisting navigation state.
|
||||
*/
|
||||
export function useNavigationPersistence(storage: any, persistenceKey: string) {
|
||||
const [initialNavigationState, setInitialNavigationState] = useState()
|
||||
// const isMounted = useIsMounted()
|
||||
|
||||
const initNavState = navigationRestoredDefaultState(Config.persistNavigation)
|
||||
const [isRestored, setIsRestored] = useState(initNavState)
|
||||
|
||||
const routeNameRef = useRef<string | undefined>()
|
||||
|
||||
const onNavigationStateChange = (state) => {
|
||||
const previousRouteName = routeNameRef.current
|
||||
const currentRouteName = getActiveRouteName(state)
|
||||
|
||||
if (previousRouteName !== currentRouteName) {
|
||||
// track screens.
|
||||
if (__DEV__) {
|
||||
// console.tron.log(currentRouteName)
|
||||
console.log(currentRouteName)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the current route name for later comparison
|
||||
routeNameRef.current = currentRouteName
|
||||
|
||||
// Persist state to storage
|
||||
storage.save(persistenceKey, state)
|
||||
}
|
||||
|
||||
const restoreState = async () => {
|
||||
try {
|
||||
const state = await storage.load(persistenceKey)
|
||||
if (state) setInitialNavigationState(state)
|
||||
} finally {
|
||||
// if (isMounted()) setIsRestored(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestored) restoreState()
|
||||
}, [isRestored])
|
||||
|
||||
return { onNavigationStateChange, restoreState, isRestored, initialNavigationState }
|
||||
}
|
||||
|
||||
/**
|
||||
* use this to navigate without the navigation
|
||||
* prop. If you have access to the navigation prop, do not use this.
|
||||
* More info: https://reactnavigation.org/docs/navigating-without-navigation-prop/
|
||||
*/
|
||||
export function navigate(name: any, params?: any) {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate(name as never, params as never)
|
||||
}
|
||||
}
|
||||
|
||||
export function goBack() {
|
||||
if (navigationRef.isReady() && navigationRef.canGoBack()) {
|
||||
navigationRef.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRoot(params = { index: 0, routes: [] }) {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.resetRoot(params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useState} from 'react'
|
||||
import {Switch, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import {colors, spacing, useThemeColor} from '../theme'
|
||||
import {SettingsStackScreenProps} from '../navigation'
|
||||
import {
|
||||
Icon,
|
||||
ListItem,
|
||||
Screen,
|
||||
Text,
|
||||
Card,
|
||||
Loading,
|
||||
ErrorModal,
|
||||
InfoModal,
|
||||
BottomModal,
|
||||
} from '../components'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {useStores} from '../models'
|
||||
import AppError from '../utils/AppError'
|
||||
import {ResultModalInfo} from './Wallet/ResultModalInfo'
|
||||
import {Database, Wallet} from '../services'
|
||||
|
||||
export const BackupScreen: FC<SettingsStackScreenProps<'Backup'>> = observer(function BackupScreen(_props) {
|
||||
const {navigation} = _props
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const {userSettingsStore, proofsStore} = useStores()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLocalBackupOn, setIsLocalBackupOn] = useState<boolean>(
|
||||
userSettingsStore.isLocalBackupOn,
|
||||
)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [info, setInfo] = useState('')
|
||||
const [isBackupModalVisible, setIsBackupModalVisible] =
|
||||
useState<boolean>(false)
|
||||
const [backupResultMessage, setBackupResultMessage] = useState<string>()
|
||||
|
||||
const toggleBackupSwitch = () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const result = userSettingsStore.setIsLocalBackupOn(!isLocalBackupOn)
|
||||
setIsLocalBackupOn(result)
|
||||
|
||||
if (result === true) {
|
||||
Database.addOrUpdateProofs(proofsStore.allProofs)
|
||||
Database.addOrUpdateProofs(proofsStore.allPendingProofs, true)
|
||||
|
||||
setBackupResultMessage(
|
||||
'Your minibits tokens were backed up to local database. New tokens will be backed up automatically.',
|
||||
)
|
||||
toggleBackupModal()
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
Database.removeAllProofs()
|
||||
setIsLoading(false)
|
||||
setBackupResultMessage(
|
||||
'Your backup of minibits tokens has been deleted. New tokens will not be backed up.',
|
||||
)
|
||||
toggleBackupModal()
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBackupModal = () =>
|
||||
setIsBackupModalVisible(previousState => !previousState)
|
||||
|
||||
const gotoLocalRecovery = function () {
|
||||
navigation.navigate('LocalRecovery')
|
||||
}
|
||||
|
||||
const checkSpent = async function () {
|
||||
setIsLoading(true)
|
||||
const result = (await Wallet.checkSpent()) as {
|
||||
spentCount: number
|
||||
spentAmount: number
|
||||
} | void
|
||||
setIsLoading(false)
|
||||
|
||||
if (result && result.spentAmount > 0) {
|
||||
setInfo(
|
||||
`${result.spentCount} coins, ${result.spentAmount} sats in total were removed from the wallet.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setInfo('No spent coins found in your wallet')
|
||||
}
|
||||
|
||||
const handleError = function (e: AppError): void {
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
|
||||
return (
|
||||
<Screen style={$screen}>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text preset="heading" text="Backup" style={{color: 'white'}} />
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$card}
|
||||
HeadingComponent={
|
||||
<>
|
||||
<ListItem
|
||||
tx="backupScreen.localBackup"
|
||||
subTx="backupScreen.localBackupDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faDownload"
|
||||
size={spacing.medium}
|
||||
color={
|
||||
isLocalBackupOn
|
||||
? colors.palette.success200
|
||||
: colors.palette.neutral400
|
||||
}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
RightComponent={
|
||||
<View style={$rightContainer}>
|
||||
<Switch
|
||||
onValueChange={toggleBackupSwitch}
|
||||
value={isLocalBackupOn}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
style={$item}
|
||||
/>
|
||||
{isLocalBackupOn && (
|
||||
<ListItem
|
||||
tx="backupScreen.recoveryTool"
|
||||
subTx="backupScreen.recoveryToolDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faUpload"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.focus300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
onPress={gotoLocalRecovery}
|
||||
/>
|
||||
)}
|
||||
<ListItem
|
||||
tx="backupScreen.removeSpentCoins"
|
||||
subTx="backupScreen.removeSpentCoinsDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faRecycle"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.secondary300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
onPress={checkSpent}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
<BottomModal
|
||||
isVisible={isBackupModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.6}
|
||||
style={{paddingHorizontal: spacing.small}}
|
||||
ContentComponent={
|
||||
<ResultModalInfo
|
||||
icon={'faDownload'}
|
||||
iconColor={
|
||||
isLocalBackupOn
|
||||
? colors.palette.success200
|
||||
: colors.palette.neutral400
|
||||
}
|
||||
title={
|
||||
isLocalBackupOn ? 'Local backup is on' : 'Local backup is off'
|
||||
}
|
||||
message={backupResultMessage as string}
|
||||
/>
|
||||
}
|
||||
onBackButtonPress={toggleBackupModal}
|
||||
onBackdropPress={toggleBackupModal}
|
||||
/>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const $screen: ViewStyle = {}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
height: spacing.screenHeight * 0.1,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
// flex: 1,
|
||||
padding: spacing.extraSmall,
|
||||
// alignItems: 'center',
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: 0,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
const $rightContainer: ViewStyle = {
|
||||
// padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
// marginLeft: spacing.small,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC} from 'react'
|
||||
import {ViewStyle} from 'react-native'
|
||||
import {spacing} from '../theme'
|
||||
import {Card, Screen} from '../components'
|
||||
import {TabScreenProps} from '../navigation' // @demo remove-current-line
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
|
||||
interface ContactsScreenProps extends TabScreenProps<'Contacts'> {}
|
||||
|
||||
export const ContactsScreen: FC<ContactsScreenProps> = observer(function ContactsScreen(_props) {
|
||||
useHeader({}) // default header component
|
||||
|
||||
return (
|
||||
<Screen contentContainerStyle={$screen}>
|
||||
<Card
|
||||
style={{margin: spacing.small}}
|
||||
content="Contact management has not been yet implemented"
|
||||
/>
|
||||
</Screen>
|
||||
)
|
||||
})
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
// flex: 1,
|
||||
// backgroundColor: colors.background,
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useState} from 'react'
|
||||
import {Alert, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import {colors, spacing, useThemeColor} from '../theme'
|
||||
import {SettingsStackScreenProps} from '../navigation' // @demo remove-current-line
|
||||
import {
|
||||
Icon,
|
||||
ListItem,
|
||||
Screen,
|
||||
Text,
|
||||
Card,
|
||||
Loading,
|
||||
ErrorModal,
|
||||
InfoModal,
|
||||
} from '../components'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {useStores} from '../models'
|
||||
import {translate} from '../i18n'
|
||||
import AppError from '../utils/AppError'
|
||||
import {Database} from '../services'
|
||||
import {MMKVStorage} from '../services'
|
||||
import {maxTransactionsInModel} from '../models/TransactionsStore'
|
||||
|
||||
export const DeveloperScreen: FC<SettingsStackScreenProps<'Developer'>> = observer(function DeveloperScreen(_props) {
|
||||
const {navigation} = _props
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const {transactionsStore} = useStores()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [info, setInfo] = useState('')
|
||||
|
||||
// Reset of whole model state and reload from DB
|
||||
const syncTransactionsFromDb = async function () {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await Database.getTransactionsAsync(
|
||||
maxTransactionsInModel,
|
||||
0,
|
||||
)
|
||||
|
||||
if (result && result.length > 0) {
|
||||
// remove all from the transactionsStore model
|
||||
transactionsStore.removeAllTransactions()
|
||||
|
||||
// Add last 10 from database
|
||||
transactionsStore.addTransactionsToModel(result._array)
|
||||
|
||||
setIsLoading(false)
|
||||
setInfo(
|
||||
`Reset completed. ${result.length} recent transactions were loaded from database.`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
setInfo('Reset aborted, there are no transactions in local database.')
|
||||
setIsLoading(false)
|
||||
return false
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const factoryReset = async function () {
|
||||
Alert.alert(
|
||||
'Confirmation',
|
||||
'All application data will be deleted. Are you sure you want to perform a factory reset?',
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
onPress: () => {
|
||||
// Action canceled
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Confirm',
|
||||
onPress: async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
Database.cleanAll()
|
||||
// recreate db schema
|
||||
Database.getInstance()
|
||||
MMKVStorage.clearAll()
|
||||
|
||||
setIsLoading(false)
|
||||
setInfo('Factory reset completed')
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const handleError = function (e: AppError): void {
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
|
||||
return (
|
||||
<Screen style={$screen}>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text
|
||||
preset="heading"
|
||||
tx="developerScreen.title"
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$card}
|
||||
HeadingComponent={
|
||||
<>
|
||||
<ListItem
|
||||
tx="developerScreen.transactions"
|
||||
subText={translate(
|
||||
'developerScreen.transactionsDescription',
|
||||
{count: transactionsStore.count},
|
||||
)}
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faRotate"
|
||||
size={spacing.medium}
|
||||
color={colors.light.tint}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
RightComponent={<View style={$rightContainer} />}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={syncTransactionsFromDb}
|
||||
/>
|
||||
<ListItem
|
||||
tx="developerScreen.reset"
|
||||
subTx="developerScreen.resetDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faXmark"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.angry500}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
RightComponent={<View style={$rightContainer} />}
|
||||
style={$item}
|
||||
// bottomSeparator={true}
|
||||
onPress={factoryReset}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{isLoading && <Loading />}
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
})
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
height: spacing.screenHeight * 0.1,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
// flex: 1,
|
||||
padding: spacing.extraSmall,
|
||||
// alignItems: 'center',
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: 0,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
const $rightContainer: ViewStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
marginLeft: spacing.small,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react"
|
||||
import { ErrorDetails } from "./ErrorDetails"
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
catchErrors: "always" | "dev" | "prod" | "never"
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
errorInfo: ErrorInfo | null
|
||||
}
|
||||
|
||||
/**
|
||||
* This component handles whenever the user encounters a JS error in the
|
||||
* app. It follows the "error boundary" pattern in React. We're using a
|
||||
* class component because according to the documentation, only class
|
||||
* components can be error boundaries.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Error-Boundary.md)
|
||||
* - [React Error Boundaries](https://reactjs.org/docs/error-boundaries.html)
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state = { error: null, errorInfo: null }
|
||||
|
||||
// If an error in a child is encountered, this will run
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Catch errors in any components below and re-render with error message
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
})
|
||||
|
||||
// You can also log error messages to an error reporting service here
|
||||
// This is a great place to put BugSnag, Sentry, crashlytics, etc:
|
||||
// reportCrash(error)
|
||||
}
|
||||
|
||||
// Reset the error back to null
|
||||
resetError = () => {
|
||||
this.setState({ error: null, errorInfo: null })
|
||||
}
|
||||
|
||||
// To avoid unnecessary re-renders
|
||||
shouldComponentUpdate(nextProps: Readonly<Props>, nextState: Readonly<State>): boolean {
|
||||
return nextState.error !== this.state.error
|
||||
}
|
||||
|
||||
// Only enable if we're catching errors in the right environment
|
||||
isEnabled(): boolean {
|
||||
return (
|
||||
this.props.catchErrors === "always" ||
|
||||
(this.props.catchErrors === "dev" && __DEV__) ||
|
||||
(this.props.catchErrors === "prod" && !__DEV__)
|
||||
)
|
||||
}
|
||||
|
||||
// Render an error UI if there's an error; otherwise, render children
|
||||
render() {
|
||||
return this.isEnabled() && this.state.error ? (
|
||||
<ErrorDetails
|
||||
onReset={this.resetError}
|
||||
error={this.state.error}
|
||||
errorInfo={this.state.errorInfo}
|
||||
/>
|
||||
) : (
|
||||
this.props.children
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { ErrorInfo } from "react"
|
||||
import { ScrollView, TextStyle, View, ViewStyle } from "react-native"
|
||||
import { Button, Icon, Screen, Text } from "../../components"
|
||||
import { colors, spacing } from "../../theme"
|
||||
|
||||
export interface ErrorDetailsProps {
|
||||
error: Error
|
||||
errorInfo: ErrorInfo | null
|
||||
onReset(): void
|
||||
}
|
||||
|
||||
export function ErrorDetails(props: ErrorDetailsProps) {
|
||||
|
||||
return (
|
||||
|
||||
<Screen
|
||||
preset="fixed"
|
||||
safeAreaEdges={["top", "bottom"]}
|
||||
contentContainerStyle={$contentContainer}
|
||||
>
|
||||
<View style={$topSection}>
|
||||
<Icon icon="faInfoCircle" size={64} />
|
||||
<Text style={$heading} preset="subheading" tx="errorScreen.title" />
|
||||
<Text tx="errorScreen.friendlySubtitle" />
|
||||
</View>
|
||||
|
||||
<ScrollView style={$errorSection} contentContainerStyle={$errorSectionContentContainer}>
|
||||
<Text style={$errorContent} weight="bold" text={`${props.error}`.trim()} />
|
||||
<Text
|
||||
selectable
|
||||
style={$errorBacktrace}
|
||||
text={`${props.errorInfo?.componentStack}`.trim()}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<Button
|
||||
preset="secondary"
|
||||
style={$resetButton}
|
||||
onPress={props.onReset}
|
||||
tx="errorScreen.reset"
|
||||
/>
|
||||
</Screen>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
const $contentContainer: ViewStyle = {
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.large,
|
||||
paddingTop: spacing.extraLarge,
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $topSection: ViewStyle = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
}
|
||||
|
||||
const $heading: TextStyle = {
|
||||
color: colors.palette.angry100,
|
||||
marginBottom: spacing.medium,
|
||||
}
|
||||
|
||||
const $errorSection: ViewStyle = {
|
||||
flex: 2,
|
||||
backgroundColor: colors.palette.neutral200,
|
||||
marginVertical: spacing.medium,
|
||||
borderRadius: 6,
|
||||
}
|
||||
|
||||
const $errorSectionContentContainer: ViewStyle = {
|
||||
padding: spacing.medium,
|
||||
}
|
||||
|
||||
const $errorContent: TextStyle = {
|
||||
color: colors.palette.neutral100,
|
||||
}
|
||||
|
||||
const $errorBacktrace: TextStyle = {
|
||||
marginTop: spacing.medium,
|
||||
color: colors.palette.neutral100,
|
||||
}
|
||||
|
||||
const $resetButton: ViewStyle = {
|
||||
backgroundColor: colors.palette.angry100,
|
||||
paddingHorizontal: spacing.huge,
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useState, useEffect, useRef, useMemo} from 'react'
|
||||
import {
|
||||
ImageStyle,
|
||||
TextStyle,
|
||||
ViewStyle,
|
||||
View,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from 'react-native'
|
||||
import {formatDistance, toDate} from 'date-fns'
|
||||
import {useThemeColor, spacing, colors, typography} from '../theme'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Screen,
|
||||
Text,
|
||||
Card,
|
||||
ListItem,
|
||||
ErrorModal,
|
||||
InfoModal,
|
||||
Loading,
|
||||
} from '../components'
|
||||
import {SettingsStackScreenProps} from '../navigation'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {log} from '../utils/logger'
|
||||
import {Database} from '../services'
|
||||
import AppError from '../utils/AppError'
|
||||
import {BackupProof} from '../models/Proof'
|
||||
|
||||
interface LocalRecoveryScreenProps
|
||||
extends SettingsStackScreenProps<'LocalRecovery'> {}
|
||||
|
||||
// Number of transactions held in TransactionsStore model
|
||||
const limit = 10
|
||||
|
||||
export const LocalRecoveryScreen: FC<LocalRecoveryScreenProps> =
|
||||
function LocalRecoveryScreen(_props) {
|
||||
|
||||
const { navigation } = _props
|
||||
// const { proofsStore } = useStores()
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
|
||||
const [showPendingOnly, setShowPendingOnly] = useState<boolean>(false)
|
||||
const [showDeletedOnly, setShowDeletedOnly] = useState<boolean>(false)
|
||||
const [proofs, setProofs] = useState<BackupProof[]>([])
|
||||
const [selectedProofs, setSelectedProofs] = useState<BackupProof[]>([])
|
||||
const [info, setInfo] = useState('')
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [offset, setOffset] = useState<number>(0) // load from db those that are not already displayed
|
||||
const [isAll, setIsAll] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
getProofsList()
|
||||
// Run on component unmount (cleanup)
|
||||
return () => {}
|
||||
}, [])
|
||||
|
||||
const getProofsList = function (
|
||||
isPending: boolean = false,
|
||||
isDeleted: boolean = false,
|
||||
) {
|
||||
try {
|
||||
const proofs = Database.getProofs(limit, offset, isPending, isDeleted)
|
||||
|
||||
if (proofs.length === 0) {
|
||||
setProofs([])
|
||||
setIsAll(true)
|
||||
setOffset(0)
|
||||
} else {
|
||||
if (proofs.length < limit) {
|
||||
setIsAll(true)
|
||||
}
|
||||
setProofs(proofs)
|
||||
setOffset(0)
|
||||
}
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const getMore = function (
|
||||
isPending: boolean = false,
|
||||
isDeleted: boolean = false,
|
||||
) {
|
||||
try {
|
||||
const proofs = Database.getProofs(limit, offset, isPending, isDeleted)
|
||||
|
||||
if (proofs.length === 0) {
|
||||
setProofs([])
|
||||
setIsAll(true)
|
||||
setOffset(0)
|
||||
} else {
|
||||
if (proofs.length < limit) {
|
||||
setIsAll(true)
|
||||
}
|
||||
setProofs(prevProofs => [...prevProofs, ...proofs])
|
||||
setOffset(offset + proofs.length)
|
||||
}
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleShowPendingOnly = () =>
|
||||
setShowPendingOnly(previousState => {
|
||||
|
||||
if (previousState) {
|
||||
getProofsList()
|
||||
} else {
|
||||
getProofsList(true, false)
|
||||
}
|
||||
|
||||
setShowDeletedOnly(false)
|
||||
return !previousState
|
||||
})
|
||||
|
||||
const toggleShowDeletedOnly = async () =>
|
||||
setShowDeletedOnly(previousState => {
|
||||
if (previousState) {
|
||||
getProofsList()
|
||||
} else {
|
||||
getProofsList(false, true)
|
||||
}
|
||||
|
||||
setShowPendingOnly(false)
|
||||
return !previousState
|
||||
})
|
||||
|
||||
const toggleSelectedProof = function (proof: BackupProof) {
|
||||
setSelectedProofs(prevSelectedProofs => {
|
||||
const isSelected = prevSelectedProofs.some(
|
||||
p => p.secret === proof.secret
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
// If the proof is already selected, remove it from the array
|
||||
return prevSelectedProofs.filter(p => p.secret !== proof.secret)
|
||||
} else {
|
||||
// If the proof is not selected, add it to the array
|
||||
return [...prevSelectedProofs, proof]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/*type ProofGroup = {
|
||||
[date: string]: BackupProof[]
|
||||
}
|
||||
|
||||
|
||||
const proofGroups = proofs.reduce((groups: ProofGroup, proof: BackupProof) => {
|
||||
const date = new Date(proof.updatedAt).toLocaleString() // Get the date part of updatedAt as a string
|
||||
|
||||
if (!groups[date]) {
|
||||
groups[date] = [] // Create an array for the date if it doesn't exist
|
||||
}
|
||||
|
||||
groups[date].push(proof) // Add the proof to the corresponding date group
|
||||
return groups
|
||||
}, {}) */
|
||||
|
||||
const handleError = function (e: AppError): void {
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
const iconColor = useThemeColor('textDim')
|
||||
const dateColor = useThemeColor('textDim')
|
||||
const iconSelectedColor = useThemeColor('button')
|
||||
const activeIconColor = useThemeColor('button')
|
||||
const hintColor = colors.palette.primary200
|
||||
|
||||
|
||||
return (
|
||||
<Screen style={$screen} preset="auto">
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text
|
||||
preset="heading"
|
||||
text="Recovery tool"
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
<Text
|
||||
text="Select tokens to recover"
|
||||
preset="default"
|
||||
style={{color: hintColor}}
|
||||
/>
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$actionCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
text={'Pending only'}
|
||||
LeftComponent={
|
||||
<Icon
|
||||
containerStyle={$iconContainer}
|
||||
icon="faPaperPlane"
|
||||
size={spacing.medium}
|
||||
color={showPendingOnly ? activeIconColor : iconColor}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
onPress={toggleShowPendingOnly}
|
||||
bottomSeparator={true}
|
||||
/>
|
||||
<ListItem
|
||||
text={'Spent only'}
|
||||
LeftComponent={
|
||||
<Icon
|
||||
containerStyle={$iconContainer}
|
||||
icon="faBan"
|
||||
size={spacing.medium}
|
||||
color={showDeletedOnly ? activeIconColor : iconColor}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
onPress={toggleShowDeletedOnly}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{(proofs && proofs.length) > 0 && (
|
||||
<Card
|
||||
ContentComponent={
|
||||
<>
|
||||
{/*Object.entries(proofGroups).map(([date, proofs], index) => (
|
||||
<View key={index}>
|
||||
<ListItem
|
||||
key={index}
|
||||
text={date}
|
||||
textStyle={{color: dateColor, textAlign: 'center'}}
|
||||
bottomSeparator={true}
|
||||
/>*/}
|
||||
{proofs.map((proof: BackupProof, index: number) => {
|
||||
const isSelected = selectedProofs.some(
|
||||
p => p.secret === proof.secret,
|
||||
)
|
||||
return (
|
||||
<ListItem
|
||||
key={proof.secret}
|
||||
text={`${proof.secret}`}
|
||||
textStyle={$proofText}
|
||||
subText={`${
|
||||
proof.isPending
|
||||
? 'Pending'
|
||||
: proof.isSpent
|
||||
? 'Spent'
|
||||
: 'Received'
|
||||
}`}
|
||||
leftIcon={isSelected ? 'faCheckCircle' : 'faCircle'}
|
||||
leftIconColor={
|
||||
isSelected
|
||||
? (iconSelectedColor as string)
|
||||
: (iconColor as string)
|
||||
}
|
||||
RightComponent={
|
||||
<Text
|
||||
text={`${proof.amount}`}
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
marginHorizontal: spacing.medium,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onPress={() => toggleSelectedProof(proof)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
}
|
||||
FooterComponent={
|
||||
<View style={{alignItems: 'center'}}>
|
||||
{isAll ? (
|
||||
<Text text="List is complete" size="xs" />
|
||||
) : (
|
||||
<Button
|
||||
preset="tertiary"
|
||||
onPress={() =>
|
||||
showPendingOnly
|
||||
? getMore(true, false)
|
||||
: showDeletedOnly
|
||||
? getMore(false, true)
|
||||
: getMore()
|
||||
}
|
||||
text="View more"
|
||||
style={{minHeight: 25, paddingVertical: spacing.tiny}}
|
||||
textStyle={{fontSize: 14}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
style={$card}
|
||||
/>
|
||||
)}
|
||||
{selectedProofs.length > 0 && (
|
||||
<Button
|
||||
preset="default"
|
||||
onPress={() => Alert.alert('Not implemented yet')}
|
||||
text="Recover selected"
|
||||
style={{marginVertical: spacing.medium, alignSelf: 'center'}}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
// borderWidth: 1,
|
||||
// borderColor: 'red'
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.medium,
|
||||
height: spacing.screenHeight * 0.18,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
minHeight: spacing.screenHeight * 0.5,
|
||||
padding: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $actionCard: ViewStyle = {
|
||||
marginBottom: spacing.extraSmall,
|
||||
marginTop: -spacing.extraLarge * 2,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $cardHeading: TextStyle = {
|
||||
fontFamily: typography.primary?.medium,
|
||||
fontSize: 20,
|
||||
}
|
||||
|
||||
const $iconContainer: ViewStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.medium,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
marginHorizontal: spacing.micro,
|
||||
}
|
||||
|
||||
const $proofText: TextStyle = {
|
||||
overflow: 'hidden',
|
||||
fontSize: 14,
|
||||
}
|
||||
|
||||
const $txContainer: ViewStyle = {
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $txAmount: TextStyle = {
|
||||
fontFamily: typography.primary?.medium,
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.small,
|
||||
}
|
||||
|
||||
const $txIconContainer: ViewStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.medium,
|
||||
}
|
||||
|
||||
const $bottomContainer: ViewStyle = {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: spacing.medium,
|
||||
alignSelf: 'stretch',
|
||||
// opacity: 0,
|
||||
}
|
||||
|
||||
const $buttonReceive: ViewStyle = {
|
||||
borderTopLeftRadius: 30,
|
||||
borderBottomLeftRadius: 30,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
minWidth: 130,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { useState } from "react"
|
||||
import { ScrollView, TextStyle, View, ViewStyle } from "react-native"
|
||||
import { observer } from "mobx-react-lite"
|
||||
import { Button, Icon, ListItem, Screen, Text } from "../../components"
|
||||
import { Mint } from "../../models/Mint"
|
||||
import { MintBalance } from "../../models/Mint"
|
||||
import { Transaction, TransactionStatus, TransactionType } from "../../models/Transaction"
|
||||
import { colors, spacing, typography, useThemeColor } from "../../theme"
|
||||
|
||||
export interface TransactionListProps {
|
||||
transaction: Transaction
|
||||
}
|
||||
|
||||
|
||||
export const MintListItem = observer(function(props: {
|
||||
mint: Mint,
|
||||
mintBalance?: MintBalance,
|
||||
onMintSelect?: any,
|
||||
isSelected?: boolean,
|
||||
isSelectable: boolean
|
||||
isBlocked?: boolean
|
||||
separator: 'bottom' | 'top' | 'both' | undefined
|
||||
}) {
|
||||
|
||||
const iconSelectedColor = useThemeColor('button')
|
||||
const iconColor = useThemeColor('textDim')
|
||||
const iconBlockedColor = colors.palette.angry500
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={props.mint.mintUrl}
|
||||
text={props.mint.hostname || ''}
|
||||
subText={props.mint.shortname || ''}
|
||||
leftIcon={props.isSelectable ? props.isSelected ? 'faCheckCircle' : 'faCircle' : undefined}
|
||||
leftIconColor={props.isSelected ? iconSelectedColor as string : iconColor as string}
|
||||
rightIcon={props.isBlocked && 'faShieldHalved' || undefined}
|
||||
rightIconColor={iconBlockedColor}
|
||||
onPress={props.onMintSelect ? () => props.onMintSelect(props.mint, props.mintBalance) : undefined}
|
||||
RightComponent={props.mintBalance && <Text text={`${props.mintBalance?.balance}`} style={{alignSelf: 'center', marginRight: spacing.medium}}/>}
|
||||
style={{paddingHorizontal: spacing.small}}
|
||||
containerStyle={{alignSelf: 'stretch'}}
|
||||
bottomSeparator={props.separator === 'bottom' || props.separator === 'both'}
|
||||
topSeparator={props.separator === 'top' || props.separator === 'both'}
|
||||
/>
|
||||
)})
|
||||
@@ -0,0 +1,452 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useState} from 'react'
|
||||
import {Alert, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import {spacing, typography, useThemeColor, colors} from '../theme'
|
||||
import {SettingsStackScreenProps} from '../navigation'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Card,
|
||||
Screen,
|
||||
Loading,
|
||||
InfoModal,
|
||||
ErrorModal,
|
||||
ListItem,
|
||||
BottomModal,
|
||||
Text,
|
||||
} from '../components'
|
||||
import {Mint} from '../models/Mint'
|
||||
import {useStores} from '../models'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {MintKeys, MintKeySets, MintClient} from '../services'
|
||||
import {log} from '../utils/logger'
|
||||
import AppError from '../utils/AppError'
|
||||
import {translate} from '../i18n'
|
||||
import {MintListItem} from './Mints/MintListItem'
|
||||
|
||||
export const MintsScreen: FC<SettingsStackScreenProps<'Mints'>> = observer(function MintsScreen(_props) {
|
||||
const {navigation} = _props
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const {mintsStore, proofsStore} = useStores()
|
||||
|
||||
const [mintUrl, setMintUrl] = useState('')
|
||||
const [selectedMint, setSelectedMint] = useState<Mint | undefined>()
|
||||
const [info, setInfo] = useState('')
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isAddMintVisible, setIsAddMintVisible] = useState(false)
|
||||
|
||||
const toggleAddMintModal = async function () {
|
||||
if (isAddMintVisible) {
|
||||
setIsAddMintVisible(false)
|
||||
setMintUrl('')
|
||||
} else {
|
||||
setIsAddMintVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
const pasteMintUrl = async () => {
|
||||
const url = await Clipboard.getString()
|
||||
try {
|
||||
new URL(url)
|
||||
setMintUrl(url)
|
||||
} catch (e) {
|
||||
setInfo(translate('mintsScreen.invalidUrl'))
|
||||
}
|
||||
}
|
||||
|
||||
const pasteTestMintUrl = async () => {
|
||||
// Pastes mintUrl provided here
|
||||
setMintUrl('')
|
||||
}
|
||||
|
||||
const addMint = async function () {
|
||||
setIsAddMintVisible(false)
|
||||
|
||||
if (mintsStore.alreadyExists(mintUrl)) {
|
||||
const msg = translate('mintsScreen.mintExists')
|
||||
log.info(msg)
|
||||
setInfo(msg)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
// log.trace('Snapshot before add mint', getSnapshot(mintsStore))
|
||||
|
||||
const mintKeys: {
|
||||
keys: MintKeys
|
||||
keysets: MintKeySets
|
||||
} = await MintClient.getMintKeys(mintUrl)
|
||||
|
||||
const newMint: Mint = {
|
||||
mintUrl,
|
||||
keys: mintKeys.keys,
|
||||
keysets: mintKeys.keysets.keysets,
|
||||
}
|
||||
|
||||
mintsStore.addMint(newMint)
|
||||
|
||||
// log.trace('Snapshot after add mint', getSnapshot(mintsStore))
|
||||
|
||||
setInfo(translate('mintsScreen.mintAdded'))
|
||||
} catch (e: any) {
|
||||
setMintUrl('')
|
||||
handleError(e)
|
||||
} finally {
|
||||
setMintUrl('')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const removeMint = async function () {
|
||||
if (!selectedMint) {return}
|
||||
|
||||
const proofsByMint = proofsStore.getByMint(selectedMint.mintUrl)
|
||||
|
||||
|
||||
if (proofsByMint && proofsByMint.length > 0) {
|
||||
setInfo('Your wallet has a positive balance with this mint. Send or transfer your tokens before removing.')
|
||||
return
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Confirmation',
|
||||
'Do you want to remove this mint from the wallet?',
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
onPress: () => {
|
||||
// Action canceled
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Confirm',
|
||||
onPress: () => {
|
||||
try {
|
||||
onMintUnselect()
|
||||
mintsStore.removeMint(selectedMint as Mint)
|
||||
setInfo(translate('mintsScreen.mintRemoved'))
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const blockMint = async function () {
|
||||
if (!selectedMint) return
|
||||
try {
|
||||
mintsStore.blockMint(selectedMint as Mint)
|
||||
setInfo(translate('mintsScreen.mintBlocked'))
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
} finally {
|
||||
onMintUnselect()
|
||||
}
|
||||
}
|
||||
|
||||
const unblockMint = async function () {
|
||||
if (!selectedMint) return
|
||||
try {
|
||||
mintsStore.unblockMint(selectedMint as Mint)
|
||||
setInfo(translate('mintsScreen.mintUnblocked'))
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
} finally {
|
||||
onMintUnselect()
|
||||
}
|
||||
}
|
||||
|
||||
const onMintSelect = function (mint: Mint) {
|
||||
setSelectedMint(mint)
|
||||
}
|
||||
|
||||
const onMintUnselect = function () {
|
||||
setSelectedMint(undefined)
|
||||
}
|
||||
|
||||
const handleError = function (e: AppError) {
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
const iconColor = useThemeColor('textDim')
|
||||
|
||||
return (
|
||||
<Screen preset="auto" contentContainerStyle={$screen}>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text preset="heading" text="Manage mints" style={{color: 'white'}} />
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$actionCard}
|
||||
ContentComponent={
|
||||
<ListItem
|
||||
text={'Add mint'}
|
||||
LeftComponent={<Icon
|
||||
containerStyle={$iconContainer}
|
||||
icon="faPlus"
|
||||
size={spacing.medium}
|
||||
color={iconColor}
|
||||
/>
|
||||
}
|
||||
style={$actionItem}
|
||||
onPress={toggleAddMintModal}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{mintsStore.mintCount > 0 && (
|
||||
<Card
|
||||
style={$card}
|
||||
ContentComponent={
|
||||
<>
|
||||
{mintsStore.mints.map((mint: Mint, index: number) => (
|
||||
<MintListItem
|
||||
key={mint.mintUrl}
|
||||
mint={mint}
|
||||
onMintSelect={() => onMintSelect(mint)}
|
||||
isSelectable={true}
|
||||
isSelected={selectedMint?.mintUrl === mint.mintUrl}
|
||||
isBlocked={mintsStore.isBlocked(mint.mintUrl as string)}
|
||||
separator={index !== 0 ? 'top' : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
<BottomModal
|
||||
isVisible={selectedMint ? true : false}
|
||||
top={spacing.screenHeight * 0.5}
|
||||
ContentComponent={
|
||||
<View style={{}}>
|
||||
<ListItem
|
||||
leftIcon="faInfoCircle"
|
||||
onPress={() => Alert.alert('Not yet implemented')}
|
||||
tx={'mintsScreen.mintInfo'}
|
||||
bottomSeparator={true}
|
||||
style={{paddingHorizontal: spacing.medium}}
|
||||
/>
|
||||
{mintsStore.isBlocked(selectedMint?.mintUrl as string) ? (
|
||||
<ListItem
|
||||
leftIcon="faShieldHalved"
|
||||
onPress={unblockMint}
|
||||
tx={'mintsScreen.unblockMint'}
|
||||
bottomSeparator={true}
|
||||
style={{paddingHorizontal: spacing.medium}}
|
||||
/>
|
||||
) : (
|
||||
<ListItem
|
||||
leftIcon="faShieldHalved"
|
||||
onPress={blockMint}
|
||||
tx={'mintsScreen.blockMint'}
|
||||
bottomSeparator={true}
|
||||
style={{paddingHorizontal: spacing.medium}}
|
||||
/>
|
||||
)}
|
||||
<ListItem
|
||||
leftIcon="faPaintbrush"
|
||||
onPress={() => Alert.alert('Not yet implemented')}
|
||||
tx={'mintsScreen.rename'}
|
||||
bottomSeparator={true}
|
||||
style={{paddingHorizontal: spacing.medium}}
|
||||
/>
|
||||
<ListItem
|
||||
leftIcon="faPaintbrush"
|
||||
onPress={() => Alert.alert('Not yet implemented')}
|
||||
tx={'mintsScreen.setColor'}
|
||||
bottomSeparator={true}
|
||||
style={{paddingHorizontal: spacing.medium}}
|
||||
/>
|
||||
<ListItem
|
||||
leftIcon="faCopy"
|
||||
onPress={() => Alert.alert('Not yet implemented')}
|
||||
tx={'mintsScreen.copy'}
|
||||
bottomSeparator={true}
|
||||
style={{paddingHorizontal: spacing.medium}}
|
||||
/>
|
||||
<ListItem
|
||||
leftIcon="faXmark"
|
||||
onPress={removeMint}
|
||||
tx={'mintsScreen.removeMint'}
|
||||
bottomSeparator={true}
|
||||
style={{paddingHorizontal: spacing.medium}}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
onBackButtonPress={onMintUnselect}
|
||||
onBackdropPress={onMintUnselect}
|
||||
/>
|
||||
<BottomModal
|
||||
isVisible={isAddMintVisible ? true : false}
|
||||
ContentComponent={
|
||||
<View style={$bottomModal}>
|
||||
{mintUrl.length > 0 ? (
|
||||
<PasteMintUrlBlock
|
||||
mintUrl={mintUrl}
|
||||
addMint={addMint}
|
||||
toggleAddMintModal={toggleAddMintModal}
|
||||
/>
|
||||
) : (
|
||||
<AddMintUrlBlock
|
||||
pasteMintUrl={pasteMintUrl}
|
||||
pasteTestMintUrl={pasteTestMintUrl}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
onBackButtonPress={toggleAddMintModal}
|
||||
onBackdropPress={toggleAddMintModal}
|
||||
top={spacing.screenHeight * 0.6}
|
||||
/>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
const PasteMintUrlBlock = function (props: {
|
||||
mintUrl: string
|
||||
addMint: any
|
||||
toggleAddMintModal: any
|
||||
}) {
|
||||
return (
|
||||
<View style={{alignItems: 'center'}}>
|
||||
<Text
|
||||
preset="subheading"
|
||||
tx={'mintsScreen.mintUrl'}
|
||||
style={{marginBottom: spacing.medium}}
|
||||
/>
|
||||
<Text
|
||||
text={props.mintUrl}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
testID="addmint-button"
|
||||
tx={'mintsScreen.addMint'}
|
||||
onPress={props.addMint}
|
||||
style={{marginRight: spacing.medium}}
|
||||
/>
|
||||
<Button
|
||||
tx={'common.cancel'}
|
||||
onPress={props.toggleAddMintModal}
|
||||
preset="secondary"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const AddMintUrlBlock = function (props: {
|
||||
pasteMintUrl: any
|
||||
pasteTestMintUrl: any
|
||||
}) {
|
||||
return (
|
||||
<View style={{alignItems: 'center'}}>
|
||||
<Text
|
||||
preset="subheading"
|
||||
tx="mintsScreen.mintUrlHint"
|
||||
style={{marginBottom: spacing.medium}}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
testID="pasteminturl-button"
|
||||
tx={'common.paste'}
|
||||
onPress={props.pasteMintUrl}
|
||||
style={{marginRight: spacing.medium}}
|
||||
/>
|
||||
<Button
|
||||
tx={'common.scan'}
|
||||
onPress={() => Alert.alert('Not yet implemented')}
|
||||
preset="secondary"
|
||||
/>
|
||||
</View>
|
||||
{/*<Button
|
||||
text="Paste test mint"
|
||||
onPress={props.pasteTestMintUrl}
|
||||
style={{marginTop: spacing.medium}}
|
||||
preset="secondary"
|
||||
/>*/}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
height: spacing.screenHeight * 0.18,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $actionCard: ViewStyle = {
|
||||
marginBottom: spacing.extraSmall,
|
||||
marginTop: -spacing.extraLarge * 1.5,
|
||||
// padding: 0,
|
||||
}
|
||||
|
||||
const $actionItem: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $cardHeading: TextStyle = {
|
||||
fontFamily: typography.primary?.medium,
|
||||
fontSize: 20,
|
||||
}
|
||||
|
||||
const $cardContent: TextStyle = {
|
||||
fontSize: 14,
|
||||
}
|
||||
|
||||
const $bottomContainer: ViewStyle = {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: spacing.large,
|
||||
alignSelf: 'stretch',
|
||||
}
|
||||
|
||||
const $bottomModal: ViewStyle = {
|
||||
padding: spacing.small,
|
||||
}
|
||||
|
||||
const $textField: ViewStyle = {
|
||||
marginBottom: spacing.large,
|
||||
}
|
||||
|
||||
const $buttonContainer: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'center',
|
||||
marginTop: spacing.medium,
|
||||
}
|
||||
|
||||
const $iconContainer: ViewStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.medium,
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useState, useCallback} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {Alert, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import {spacing, useThemeColor, colors} from '../theme'
|
||||
import {WalletStackScreenProps} from '../navigation'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Card,
|
||||
Screen,
|
||||
Loading,
|
||||
InfoModal,
|
||||
ErrorModal,
|
||||
ListItem,
|
||||
BottomModal,
|
||||
Text,
|
||||
} from '../components'
|
||||
import {Mint} from '../models/Mint'
|
||||
import {Token} from '../models/Token'
|
||||
import {Transaction, TransactionStatus} from '../models/Transaction'
|
||||
import {useStores} from '../models'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {Wallet} from '../services'
|
||||
import {log} from '../utils/logger'
|
||||
import AppError from '../utils/AppError'
|
||||
|
||||
import {
|
||||
decodeToken,
|
||||
getTokenAmounts,
|
||||
getMintsFromToken,
|
||||
} from '../services/cashuHelpers'
|
||||
import {ResultModalInfo} from './Wallet/ResultModalInfo'
|
||||
import {MintListItem} from './Mints/MintListItem'
|
||||
|
||||
export const ReceiveScreen: FC<WalletStackScreenProps<'Receive'>> = observer(
|
||||
function ReceiveScreen({route, navigation}) {
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const {mintsStore} = useStores()
|
||||
|
||||
const [token, setToken] = useState<Token | undefined>()
|
||||
const [encodedToken, setEncodedToken] = useState<string | undefined>()
|
||||
const [amountToReceive, setAmountToReceive] = useState<number>(0)
|
||||
const [receivedAmount, setReceivedAmount] = useState<number>(0)
|
||||
const [transactionStatus, setTransactionStatus] = useState<
|
||||
TransactionStatus | undefined
|
||||
>()
|
||||
const [memo, setMemo] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
|
||||
const [resultModalInfo, setResultModalInfo] = useState<
|
||||
{status: TransactionStatus; message: string} | undefined
|
||||
>()
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
log.trace('scanned effect triggered')
|
||||
|
||||
if (!route.params || !route.params?.scannedEncodedToken) {
|
||||
return
|
||||
}
|
||||
|
||||
const encoded = route.params?.scannedEncodedToken
|
||||
setEncodedToken(encoded)
|
||||
onEncodedToken(encoded)
|
||||
|
||||
}, [route.params?.scannedEncodedToken]),
|
||||
)
|
||||
|
||||
const resetState = function () {
|
||||
setToken(undefined)
|
||||
setEncodedToken(undefined)
|
||||
setAmountToReceive(0)
|
||||
setReceivedAmount(0)
|
||||
setTransactionStatus(undefined)
|
||||
setMemo('')
|
||||
setInfo('')
|
||||
setError(undefined)
|
||||
setIsLoading(false)
|
||||
setIsResultModalVisible(false)
|
||||
setResultModalInfo(undefined)
|
||||
// setIsQRCodeModalVisible(false)
|
||||
}
|
||||
|
||||
const gotoScan = function () {
|
||||
navigation.navigate('Scan')
|
||||
}
|
||||
|
||||
// const toggleQRCodeModal = () => setIsQRCodeModalVisible(previousState => !previousState)
|
||||
const toggleResultModal = () =>
|
||||
setIsResultModalVisible(previousState => !previousState)
|
||||
|
||||
const onPasteEncodedToken = async function () {
|
||||
const encoded = await Clipboard.getString()
|
||||
if (!encoded) {
|
||||
setInfo('Copy received token first, then paste')
|
||||
return
|
||||
}
|
||||
|
||||
setEncodedToken(encoded)
|
||||
return onEncodedToken(encoded)
|
||||
}
|
||||
|
||||
const onEncodedToken = async function (encoded: string) {
|
||||
try {
|
||||
const decoded: Token = decodeToken(encoded)
|
||||
const tokenAmounts = getTokenAmounts(decoded)
|
||||
|
||||
log.trace('decoded token', decoded)
|
||||
log.trace('tokenAmounts', tokenAmounts)
|
||||
|
||||
setToken(decoded)
|
||||
setAmountToReceive(tokenAmounts.totalAmount)
|
||||
|
||||
if (decoded.memo && decoded.memo.length > 0) {
|
||||
setMemo(decoded.memo as string)
|
||||
}
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const receiveToken = async function () {
|
||||
setIsLoading(true)
|
||||
|
||||
const {transaction, message, error, receivedAmount} =
|
||||
await Wallet.receive(
|
||||
token as Token,
|
||||
amountToReceive,
|
||||
memo,
|
||||
encodedToken as string,
|
||||
)
|
||||
|
||||
const {status} = transaction as Transaction
|
||||
setTransactionStatus(status)
|
||||
|
||||
if (error) {
|
||||
setResultModalInfo({
|
||||
status,
|
||||
message: error.message,
|
||||
})
|
||||
} else {
|
||||
setResultModalInfo({
|
||||
status,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
if (receivedAmount) {
|
||||
setReceivedAmount(receivedAmount)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
toggleResultModal()
|
||||
}
|
||||
|
||||
const handleError = function (e: AppError): void {
|
||||
resetState()
|
||||
log.error(e.name, e.message)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
const iconColor = useThemeColor('textDim')
|
||||
|
||||
return (
|
||||
<Screen preset="auto" contentContainerStyle={$screen}>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
{token && amountToReceive > 0 ? (
|
||||
<View style={$amountContainer}>
|
||||
{receivedAmount > 0 ? (
|
||||
<>
|
||||
<Text
|
||||
preset="subheading"
|
||||
tx="receiveScreen.received"
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
<Text
|
||||
style={$amountToReceive}
|
||||
text={receivedAmount.toLocaleString()}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
preset="subheading"
|
||||
tx="receiveScreen.toReceive"
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
<Text
|
||||
style={$amountToReceive}
|
||||
text={amountToReceive.toLocaleString()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
preset="heading"
|
||||
tx="receiveScreen.title"
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
{!token && amountToReceive === 0 && (
|
||||
<Card
|
||||
style={$optionsCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
tx="receiveScreen.sharePaymentRequest"
|
||||
subTx="receiveScreen.sharePaymentRequestDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faAddressCard"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.secondary300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={() => Alert.alert('Not yet implemented')}
|
||||
/>
|
||||
<ListItem
|
||||
tx="receiveScreen.scanQRCodeToReceive"
|
||||
subTx="receiveScreen.scanQRCodeToReceiveDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faQrcode"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.success200}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={gotoScan}
|
||||
/>
|
||||
<ListItem
|
||||
tx="receiveScreen.pasteFromClipboard"
|
||||
subTx="receiveScreen.pasteFromClipboardDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faClipboard"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.accent300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
onPress={onPasteEncodedToken}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{token && amountToReceive > 0 && (
|
||||
<>
|
||||
{memo && (
|
||||
<Card
|
||||
style={[$card, {minHeight: 0}]}
|
||||
ContentComponent={
|
||||
<ListItem
|
||||
text={memo}
|
||||
LeftComponent={
|
||||
<Icon
|
||||
containerStyle={$iconContainer}
|
||||
icon="faPencil"
|
||||
size={spacing.medium}
|
||||
color={iconColor}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Card
|
||||
style={$card}
|
||||
heading={
|
||||
transactionStatus === TransactionStatus.COMPLETED
|
||||
? 'Received from'
|
||||
: 'Receive from'
|
||||
}
|
||||
headingStyle={{textAlign: 'center', padding: spacing.small}}
|
||||
ContentComponent={
|
||||
<>
|
||||
{getMintsFromToken(token).map((mintUrl, index) => {
|
||||
const mint = mintsStore.findByUrl(mintUrl)
|
||||
if (!mint) {
|
||||
return (
|
||||
<ListItem
|
||||
key={mintUrl}
|
||||
text={new URL(mintUrl).hostname}
|
||||
topSeparator={true}
|
||||
RightComponent={<Text text='New' style={{alignSelf: 'center', color: colors.palette.accent300}}/>}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<MintListItem
|
||||
key={mintUrl}
|
||||
mint={mint as Mint}
|
||||
separator={'top'}
|
||||
isSelectable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{transactionStatus === TransactionStatus.COMPLETED ? (
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={resetState}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
tx={'receiveScreen.receive'}
|
||||
onPress={receiveToken}
|
||||
style={{marginRight: spacing.medium}}
|
||||
LeftAccessory={() => (
|
||||
<Icon
|
||||
icon="faArrowDown"
|
||||
color="white"
|
||||
size={spacing.medium}
|
||||
containerStyle={{marginRight: spacing.small}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.cancel'}
|
||||
onPress={resetState}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
<BottomModal
|
||||
isVisible={isResultModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.6}
|
||||
ContentComponent={
|
||||
<>
|
||||
{resultModalInfo?.status === TransactionStatus.COMPLETED && (
|
||||
<>
|
||||
<ResultModalInfo
|
||||
icon="faCheckCircle"
|
||||
iconColor={colors.palette.success200}
|
||||
title="Success!"
|
||||
message={resultModalInfo?.message}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={resetState}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{(resultModalInfo?.status === TransactionStatus.ERROR ||
|
||||
resultModalInfo?.status === TransactionStatus.BLOCKED) && (
|
||||
<>
|
||||
<ResultModalInfo
|
||||
icon="faTriangleExclamation"
|
||||
iconColor={colors.palette.angry500}
|
||||
title="Receive failed"
|
||||
message={resultModalInfo?.message as string}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={toggleResultModal}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onBackButtonPress={toggleResultModal}
|
||||
onBackdropPress={toggleResultModal}
|
||||
/>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
height: spacing.screenHeight * 0.18,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
// flex: 1,
|
||||
padding: spacing.extraSmall,
|
||||
// alignItems: 'center',
|
||||
}
|
||||
|
||||
const $optionsCard: ViewStyle = {
|
||||
marginTop: -spacing.extraLarge * 2,
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
// marginTop: - spacing.extraLarge * 2,
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
const $iconContainer: ViewStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.medium,
|
||||
}
|
||||
|
||||
const $buttonContainer: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'center',
|
||||
}
|
||||
|
||||
const $amountContainer: ViewStyle = {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
|
||||
const $amountToReceive: TextStyle = {
|
||||
flex: 1,
|
||||
paddingTop: spacing.extraLarge + 10,
|
||||
fontSize: 52,
|
||||
fontWeight: '400',
|
||||
textAlignVertical: 'center',
|
||||
color: 'white',
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, {FC, useEffect, useState} from 'react'
|
||||
import {
|
||||
ImageStyle,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
useColorScheme,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
} from 'react-native'
|
||||
import {WalletStackScreenProps} from '../navigation'
|
||||
import {CameraScreen} from 'react-native-camera-kit'
|
||||
import {spacing, typography} from '../theme'
|
||||
import {Token} from '../models/Token'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {log} from '../utils/logger'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {decodeInvoice, decodeToken} from '../services/cashuHelpers'
|
||||
|
||||
export const ScanScreen: FC<WalletStackScreenProps<'Scan'>> = function ScanScreen(_props) {
|
||||
const {navigation, route} = _props
|
||||
useHeader({
|
||||
title: 'Scan to receive',
|
||||
titleStyle: {fontFamily: typography.primary?.medium},
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const [isScanned, setIsScanned] = useState<boolean>(false)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
|
||||
|
||||
const onReadCode = function(event: any) {
|
||||
setIsScanned(true)
|
||||
const scanned = event.nativeEvent.codeStringValue
|
||||
log.trace('Scanned', scanned)
|
||||
|
||||
const routes = navigation.getState()?.routes
|
||||
const prevRouteName = routes[routes.length - 2].name
|
||||
|
||||
switch (prevRouteName) {
|
||||
case 'Receive':
|
||||
const tokenResult = handleToken(scanned)
|
||||
if (tokenResult?.isToken) {
|
||||
log.trace('Got token')
|
||||
return navigation.navigate('Receive', {
|
||||
scannedEncodedToken: tokenResult.token,
|
||||
})
|
||||
}
|
||||
handleError(scanned, 'This is not a valid cashu token')
|
||||
break
|
||||
case 'Transfer':
|
||||
const invoiceResult = handleInvoice(scanned)
|
||||
if (invoiceResult?.isInvoice) {
|
||||
log.trace('Got invoice')
|
||||
return navigation.navigate('Transfer', {
|
||||
scannedEncodedInvoice: invoiceResult.invoice,
|
||||
})
|
||||
}
|
||||
handleError(scanned, 'This is not a valid lightning invoice')
|
||||
break
|
||||
default:
|
||||
// generic scan button on wallet screen
|
||||
const tokenResult2 = handleToken(scanned)
|
||||
if (tokenResult2?.isToken) {
|
||||
log.trace('Got token')
|
||||
return navigation.navigate('Receive', {
|
||||
scannedEncodedToken: tokenResult2.token,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const invoiceResult2 = handleInvoice(scanned)
|
||||
log.trace('Got invoice')
|
||||
if (invoiceResult2?.isInvoice) {
|
||||
return navigation.navigate('Transfer', {
|
||||
scannedEncodedInvoice: invoiceResult2.invoice,
|
||||
})
|
||||
}
|
||||
|
||||
handleError(
|
||||
scanned,
|
||||
'This is not a valid cashu token nor lightning invoice',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToken = function (scanned: string) {
|
||||
try {
|
||||
let validToken: Token | undefined = undefined
|
||||
|
||||
// URL token format
|
||||
const urlToken: string = extractTokenFromURL(scanned) as string
|
||||
|
||||
if (urlToken) {
|
||||
validToken = decodeToken(urlToken) //throws
|
||||
|
||||
return {
|
||||
isToken: true,
|
||||
token: urlToken,
|
||||
}
|
||||
}
|
||||
|
||||
// raw encoded token
|
||||
const token: Token = decodeToken(scanned) // throws
|
||||
|
||||
if (token) {
|
||||
return {
|
||||
isToken: true,
|
||||
token: scanned,
|
||||
}
|
||||
}
|
||||
|
||||
} catch (tokenError: any) {
|
||||
return {
|
||||
isToken: false,
|
||||
error: tokenError.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleInvoice = function (scanned: string) {
|
||||
try {
|
||||
// Attempt to decode the scanned content as a lightning invoice
|
||||
let invoice: any = {}
|
||||
|
||||
if (scanned.startsWith('lightning:')) {
|
||||
const trimmed = scanned.replace('lightning:', '')
|
||||
invoice = decodeInvoice(trimmed)
|
||||
|
||||
if (invoice) {
|
||||
return {
|
||||
isInvoice: true,
|
||||
invoice: trimmed,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invoice = decodeInvoice(scanned)
|
||||
|
||||
if (invoice) {
|
||||
return {
|
||||
isInvoice: true,
|
||||
invoice: scanned
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
} catch (invoiceError: any) {
|
||||
return {
|
||||
isInvoice: false,
|
||||
error: invoiceError.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (scanned: string, message: string) => {
|
||||
Alert.alert(message, scanned, [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => setIsScanned(false),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const extractTokenFromURL = (url: string) => {
|
||||
try {
|
||||
const parsedURL = new URL(url)
|
||||
const tokenParam = parsedURL.searchParams.get('token')
|
||||
|
||||
if (tokenParam) {
|
||||
return tokenParam
|
||||
}
|
||||
|
||||
return undefined // No token parameter found
|
||||
} catch (e: any) {
|
||||
return undefined // Invalid URL
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CameraScreen
|
||||
actions={{rightButtonText: 'Done', leftButtonText: 'Cancel'}}
|
||||
// onBottomButtonPressed={(event) => onBottomButtonPressed(event)}
|
||||
scanBarcode
|
||||
onReadCode={event => (isScanned ? undefined : onReadCode(event))}
|
||||
hideControls
|
||||
// showFrame
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const $container: ViewStyle = {
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const $barcodes: TextStyle = {
|
||||
fontSize: 20,
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
|
||||
const $bottomContainer: ViewStyle = {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
alignSelf: 'stretch',
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useState} from 'react'
|
||||
import {Switch, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import {colors, spacing, useThemeColor} from '../theme'
|
||||
import {SettingsStackScreenProps} from '../navigation' // @demo remove-current-line
|
||||
import {
|
||||
Icon,
|
||||
ListItem,
|
||||
Screen,
|
||||
Text,
|
||||
Card,
|
||||
Loading,
|
||||
ErrorModal,
|
||||
InfoModal,
|
||||
BottomModal,
|
||||
} from '../components'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {useStores} from '../models'
|
||||
import AppError from '../utils/AppError'
|
||||
import {ResultModalInfo} from './Wallet/ResultModalInfo'
|
||||
|
||||
export const SecurityScreen: FC<SettingsStackScreenProps<'Security'>> = observer(function SecurityScreen(_props) {
|
||||
const {navigation} = _props
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const {userSettingsStore} = useStores()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isStorageEncrypted, setIsStorageEncrypted] = useState<boolean>(
|
||||
userSettingsStore.isStorageEncrypted,
|
||||
)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isEncryptionModalVisible, setIsEncryptionModalVisible] =
|
||||
useState<boolean>(false)
|
||||
const [encryptionResultMessage, setEncryptionResultMessage] =
|
||||
useState<string>()
|
||||
|
||||
const toggleEncryptedSwitch = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const result = await userSettingsStore.setIsStorageEncrypted(
|
||||
!isStorageEncrypted,
|
||||
)
|
||||
|
||||
setIsStorageEncrypted(result)
|
||||
setIsLoading(false)
|
||||
|
||||
if (result === true) {
|
||||
setEncryptionResultMessage(
|
||||
'Storage has been AES encrypted with the key stored in the device secure keys storage',
|
||||
)
|
||||
toggleEncryptionModal()
|
||||
return
|
||||
}
|
||||
|
||||
setEncryptionResultMessage('Storage encryption has been disabled')
|
||||
toggleEncryptionModal()
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEncryptionModal = () =>
|
||||
setIsEncryptionModalVisible(previousState => !previousState)
|
||||
|
||||
const handleError = function (e: AppError): void {
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
|
||||
return (
|
||||
<Screen style={$screen}>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text preset="heading" text="Security" style={{color: 'white'}} />
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$card}
|
||||
HeadingComponent={
|
||||
<ListItem
|
||||
tx="securityScreen.encryptStorage"
|
||||
subTx="securityScreen.encryptStorageDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon={isStorageEncrypted ? 'faLock' : 'faLockOpen'}
|
||||
size={spacing.medium}
|
||||
color={
|
||||
isStorageEncrypted
|
||||
? colors.palette.success200
|
||||
: colors.palette.neutral400
|
||||
}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
RightComponent={
|
||||
<View style={$rightContainer}>
|
||||
<Switch
|
||||
onValueChange={toggleEncryptedSwitch}
|
||||
value={isStorageEncrypted}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
style={$item}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
<BottomModal
|
||||
isVisible={isEncryptionModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.6}
|
||||
// style={{marginHorizontal: spacing.extraSmall}}
|
||||
ContentComponent={
|
||||
<ResultModalInfo
|
||||
icon={isStorageEncrypted ? 'faLock' : 'faLockOpen'}
|
||||
iconColor={
|
||||
isStorageEncrypted
|
||||
? colors.palette.success200
|
||||
: colors.palette.neutral400
|
||||
}
|
||||
title={
|
||||
isStorageEncrypted ? 'Encryption is on' : 'Encryption is off'
|
||||
}
|
||||
message={encryptionResultMessage as string}
|
||||
/>
|
||||
}
|
||||
onBackButtonPress={toggleEncryptionModal}
|
||||
onBackdropPress={toggleEncryptionModal}
|
||||
/>
|
||||
{error && <ErrorModal error={error} />}
|
||||
</Screen>
|
||||
)
|
||||
})
|
||||
|
||||
const $screen: ViewStyle = {}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
height: spacing.screenHeight * 0.1,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
// flex: 1,
|
||||
padding: spacing.extraSmall,
|
||||
// alignItems: 'center',
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: 0,
|
||||
}
|
||||
|
||||
const $bottomModal: ViewStyle = {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.large,
|
||||
paddingHorizontal: spacing.small,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
const $rightContainer: ViewStyle = {
|
||||
// padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
// marginLeft: spacing.small,
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {getSnapshot} from 'mobx-state-tree'
|
||||
import React, {
|
||||
FC,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {
|
||||
UIManager,
|
||||
Platform,
|
||||
Alert,
|
||||
TextInput,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
LayoutAnimation,
|
||||
ScrollView,
|
||||
Share,
|
||||
} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import QRCode from 'react-native-qrcode-svg'
|
||||
import {spacing, typography, useThemeColor, colors} from '../theme'
|
||||
import {useSafeAreaInsetsStyle} from '../utils/useSafeAreaInsetsStyle'
|
||||
import {WalletStackScreenProps} from '../navigation'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Card,
|
||||
Screen,
|
||||
Loading,
|
||||
InfoModal,
|
||||
ErrorModal,
|
||||
ListItem,
|
||||
BottomModal,
|
||||
Text,
|
||||
} from '../components'
|
||||
import {TransactionStatus, Transaction} from '../models/Transaction'
|
||||
import {useStores} from '../models'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {MintKeys, MintKeySets, MintClient, Wallet} from '../services'
|
||||
import {log} from '../utils/logger'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {translate} from '../i18n'
|
||||
|
||||
import {MintBalance} from '../models/Mint'
|
||||
import {MintListItem} from './Mints/MintListItem'
|
||||
import EventEmitter from '../utils/eventEmitter'
|
||||
import {ResultModalInfo} from './Wallet/ResultModalInfo'
|
||||
|
||||
if (
|
||||
Platform.OS === 'android' &&
|
||||
UIManager.setLayoutAnimationEnabledExperimental
|
||||
) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true)
|
||||
}
|
||||
|
||||
export const SendScreen: FC<WalletStackScreenProps<'Send'>> = observer(
|
||||
function SendScreen({route, navigation}) {
|
||||
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const {proofsStore, mintsStore} = useStores()
|
||||
const amountInputRef = useRef<TextInput>(null)
|
||||
const memoInputRef = useRef<TextInput>(null)
|
||||
// const tokenInputRef = useRef<TextInput>(null)
|
||||
|
||||
|
||||
const [encodedTokenToSend, setEncodedTokenToSend] = useState<string | undefined>()
|
||||
const [amountToSend, setAmountToSend] = useState<string>('')
|
||||
const [memo, setMemo] = useState('')
|
||||
const [availableMintBalances, setAvailableMintBalances] = useState<
|
||||
MintBalance[]
|
||||
>([])
|
||||
const [mintBalanceToSendFrom, setMintBalanceToSendFrom] = useState<
|
||||
MintBalance | undefined
|
||||
>()
|
||||
const [transactionStatus, setTransactionStatus] = useState<
|
||||
TransactionStatus | undefined
|
||||
>()
|
||||
const [transactionId, setTransactionId] = useState<number | undefined>()
|
||||
const [info, setInfo] = useState('')
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isAmountEndEditing, setIsAmountEndEditing] = useState<boolean>(false)
|
||||
const [isSharedAsText, setIsSharedAsText] = useState<boolean>(false)
|
||||
const [isSharedAsQRCode, setIsSharedAsQRCode] = useState<boolean>(false)
|
||||
const [resultModalInfo, setResultModalInfo] = useState<{status: TransactionStatus, message: string} | undefined>()
|
||||
const [isMemoEndEditing, setIsMemoEndEditing] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [isMintSelectorVisible, setIsMintSelectorVisible] = useState(false)
|
||||
const [isSendModalVisible, setIsSendModalVisible] = useState(false)
|
||||
const [isQRModalVisible, setIsQRModalVisible] = useState(false)
|
||||
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const focus = () => {
|
||||
amountInputRef && amountInputRef.current
|
||||
? amountInputRef.current.focus()
|
||||
: false
|
||||
}
|
||||
|
||||
|
||||
const timer = setTimeout(() => focus(), 100)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleSendCompleted = (transactionIds: number[]) => {
|
||||
log.trace('handleSendCompleted event handler trigerred')
|
||||
|
||||
if (!transactionId) return
|
||||
// Filter and handle events for a specific transactionId
|
||||
if (transactionIds.includes(transactionId)) {
|
||||
log.trace(
|
||||
'Sent coins have been claimed by receiver for tx',
|
||||
transactionId,
|
||||
)
|
||||
|
||||
setResultModalInfo({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
message: `Done! ${amountToSend} sats were received by the payee.`,
|
||||
})
|
||||
|
||||
setTransactionStatus(TransactionStatus.COMPLETED)
|
||||
setIsQRModalVisible(false)
|
||||
setIsSendModalVisible(false)
|
||||
setIsResultModalVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to the 'sendCompleted' event
|
||||
EventEmitter.on('sendCompleted', handleSendCompleted)
|
||||
|
||||
// Unsubscribe from the 'sendCompleted' event on component unmount
|
||||
return () => {
|
||||
EventEmitter.off('sendCompleted', handleSendCompleted)
|
||||
}
|
||||
}, [transactionId])
|
||||
|
||||
const toggleSendModal = () =>
|
||||
setIsSendModalVisible(previousState => !previousState)
|
||||
const toggleQRModal = () =>
|
||||
setIsQRModalVisible(previousState => !previousState)
|
||||
const toggleResultModal = () =>
|
||||
setIsResultModalVisible(previousState => !previousState)
|
||||
|
||||
|
||||
const onAmountEndEditing = function () {
|
||||
try {
|
||||
const amount = parseInt(amountToSend)
|
||||
|
||||
if (!amount || amount === 0) {
|
||||
setInfo('Amount should be positive number')
|
||||
return
|
||||
}
|
||||
|
||||
const availableBalances =
|
||||
proofsStore.getMintBalancesWithEnoughBalance(amount)
|
||||
|
||||
if (availableBalances.length === 0) {
|
||||
setInfo('There is not enough funds to send this amount')
|
||||
return
|
||||
}
|
||||
|
||||
log.trace(
|
||||
'availableBalances',
|
||||
availableBalances.length
|
||||
)
|
||||
|
||||
setAvailableMintBalances(availableBalances)
|
||||
|
||||
// Set mint to send from immediately if only one is available
|
||||
if (availableBalances.length === 1) {
|
||||
setMintBalanceToSendFrom(availableBalances[0])
|
||||
}
|
||||
|
||||
setIsAmountEndEditing(true)
|
||||
|
||||
memoInputRef && memoInputRef.current
|
||||
? memoInputRef.current.focus()
|
||||
: false
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const onMemoEndEditing = function () {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setIsMemoEndEditing(true)
|
||||
}
|
||||
|
||||
const onMemoDone = function () {
|
||||
if (parseInt(amountToSend) > 0) {
|
||||
memoInputRef && memoInputRef.current
|
||||
? memoInputRef.current.blur()
|
||||
: false
|
||||
amountInputRef && amountInputRef.current
|
||||
? amountInputRef.current.blur()
|
||||
: false
|
||||
onMemoEndEditing()
|
||||
} else {
|
||||
amountInputRef && amountInputRef.current
|
||||
? amountInputRef.current.focus()
|
||||
: false
|
||||
}
|
||||
}
|
||||
const onShareAsText = function () {
|
||||
// if tx has been already executed, re-open SendModal
|
||||
if (transactionStatus === TransactionStatus.PENDING) {
|
||||
toggleSendModal() // open
|
||||
return
|
||||
}
|
||||
|
||||
setIsSharedAsText(true)
|
||||
setIsSharedAsQRCode(false)
|
||||
// pass share kind directly to avoid delayed state update
|
||||
return onShare('TEXT')
|
||||
}
|
||||
|
||||
const onShareAsQRCode = function () {
|
||||
// if tx has been already executed, re-open QRCodeModal
|
||||
if (transactionStatus === TransactionStatus.PENDING) {
|
||||
toggleQRModal() // open
|
||||
return
|
||||
}
|
||||
|
||||
setIsSharedAsQRCode(true)
|
||||
setIsSharedAsText(false)
|
||||
// pass share kind directly to avoid delayed state update
|
||||
return onShare('QRCODE')
|
||||
}
|
||||
|
||||
const onShare = async function (as: 'TEXT' | 'QRCODE'): Promise<void> {
|
||||
if (amountToSend.length === 0) {
|
||||
setInfo('Provide the amount you want to send')
|
||||
return
|
||||
}
|
||||
|
||||
// Skip mint modal and send immediately if only one mint is available
|
||||
if (availableMintBalances.length === 1) {
|
||||
|
||||
const result = await send()
|
||||
|
||||
if (result.error) {
|
||||
setResultModalInfo({
|
||||
status: result.transaction?.status as TransactionStatus,
|
||||
message: result.error.message,
|
||||
})
|
||||
setIsResultModalVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (as === 'TEXT') {
|
||||
toggleSendModal()
|
||||
}
|
||||
if (as === 'QRCODE') {
|
||||
toggleQRModal()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-select mint with highest balance and show mint modal to confirm which mint to send from
|
||||
setMintBalanceToSendFrom(availableMintBalances[0])
|
||||
setIsMintSelectorVisible(true)
|
||||
// toggleMintModal() // open
|
||||
}
|
||||
|
||||
const onMintBalanceSelect = function (balance: MintBalance) {
|
||||
setMintBalanceToSendFrom(balance)
|
||||
}
|
||||
|
||||
const onMintBalanceConfirm = async function () {
|
||||
if (!mintBalanceToSendFrom) return
|
||||
|
||||
const result = await send()
|
||||
|
||||
if (result.error) {
|
||||
setResultModalInfo({
|
||||
status: result.transaction?.status as TransactionStatus,
|
||||
message: result.error.message,
|
||||
})
|
||||
setIsResultModalVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
isSharedAsText && toggleSendModal() // open
|
||||
isSharedAsQRCode && toggleQRModal() // open
|
||||
|
||||
setIsMintSelectorVisible(false)
|
||||
}
|
||||
|
||||
const onMintBalanceCancel = async function () {
|
||||
setIsMintSelectorVisible(false)
|
||||
}
|
||||
|
||||
const send = async function () {
|
||||
setIsLoading(true)
|
||||
|
||||
const result = await Wallet.send(
|
||||
mintBalanceToSendFrom as MintBalance,
|
||||
parseInt(amountToSend),
|
||||
memo,
|
||||
)
|
||||
|
||||
const {status, id} = result.transaction as Transaction
|
||||
setTransactionStatus(status)
|
||||
setTransactionId(id)
|
||||
|
||||
if (result.encodedTokenToSend) {
|
||||
setEncodedTokenToSend(result.encodedTokenToSend)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
return result
|
||||
}
|
||||
|
||||
const onShareToApp = async () => {
|
||||
try {
|
||||
const result = await Share.share({
|
||||
message: encodedTokenToSend as string,
|
||||
})
|
||||
|
||||
if (result.action === Share.sharedAction) {
|
||||
toggleSendModal()
|
||||
setTimeout(
|
||||
() =>
|
||||
setInfo(
|
||||
'Coins have been shared, waiting to be claimed by receiver',
|
||||
),
|
||||
500,
|
||||
)
|
||||
} else if (result.action === Share.dismissedAction) {
|
||||
setInfo(
|
||||
'Sharing cancelled, coins are waiting to be claimed by receiver',
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const onCopy = function () {
|
||||
try {
|
||||
Clipboard.setString(encodedTokenToSend as string)
|
||||
} catch (e: any) {
|
||||
setInfo(`Could not copy: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleError = function(e: AppError): void {
|
||||
// TODO resetState() on all tx data on error? Or save txId to state and allow retry / recovery?
|
||||
setIsSendModalVisible(false)
|
||||
setIsQRModalVisible(false)
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
// const inputBg = useThemeColor('background')
|
||||
|
||||
return (
|
||||
<Screen preset="auto" contentContainerStyle={$screen}>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text
|
||||
preset="subheading"
|
||||
text="Amount to send"
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
<View style={$amountContainer}>
|
||||
<TextInput
|
||||
ref={amountInputRef}
|
||||
onChangeText={amount => setAmountToSend(amount)}
|
||||
// onFocus={() => setIsAmountEndEditing(false)}
|
||||
onEndEditing={onAmountEndEditing}
|
||||
value={amountToSend}
|
||||
style={$amountInput}
|
||||
maxLength={9}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus={true}
|
||||
editable={
|
||||
transactionStatus === TransactionStatus.PENDING ? false : true
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$memoCard}
|
||||
ContentComponent={
|
||||
<View style={$memoContainer}>
|
||||
<TextInput
|
||||
ref={memoInputRef}
|
||||
onChangeText={memo => setMemo(memo)}
|
||||
onEndEditing={onMemoEndEditing}
|
||||
value={`${memo}`}
|
||||
style={$memoInput}
|
||||
maxLength={200}
|
||||
keyboardType="default"
|
||||
selectTextOnFocus={true}
|
||||
placeholder="Memo for recipient"
|
||||
editable={
|
||||
transactionStatus === TransactionStatus.PENDING
|
||||
? false
|
||||
: true
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
preset="secondary"
|
||||
style={$memoButton}
|
||||
text="Done"
|
||||
onPress={onMemoDone}
|
||||
disabled={
|
||||
transactionStatus === TransactionStatus.PENDING
|
||||
? false
|
||||
: true
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
{isAmountEndEditing && isMemoEndEditing && !isMintSelectorVisible && (
|
||||
<Card
|
||||
style={$card}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
tx="sendScreen.sendToContact"
|
||||
subTx="sendScreen.sendToContactDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faAddressCard"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.secondary300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={() => Alert.alert('Not implemented yet')}
|
||||
/>
|
||||
<ListItem
|
||||
tx="sendScreen.showAsQRCode"
|
||||
subTx="sendScreen.showAsQRCodeDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faQrcode"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.success200}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={onShareAsQRCode}
|
||||
/>
|
||||
<ListItem
|
||||
tx="sendScreen.shareAsText"
|
||||
subTx="sendScreen.shareAsTextDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faShareFromSquare"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.accent300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
onPress={onShareAsText}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMintSelectorVisible &&
|
||||
transactionStatus !== TransactionStatus.PENDING && (
|
||||
<MintBalanceSelector
|
||||
availableMintBalances={availableMintBalances}
|
||||
mintBalanceToSendFrom={mintBalanceToSendFrom as MintBalance}
|
||||
onMintBalanceSelect={onMintBalanceSelect}
|
||||
onCancel={onMintBalanceCancel}
|
||||
findByUrl={mintsStore.findByUrl}
|
||||
onMintBalanceConfirm={onMintBalanceConfirm}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
<BottomModal
|
||||
isVisible={isSendModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.367}
|
||||
style={{marginHorizontal: spacing.extraSmall}}
|
||||
ContentComponent={
|
||||
<SendAsTextBlock
|
||||
toggleSendModal={toggleSendModal}
|
||||
encodedTokenToSend={encodedTokenToSend as string}
|
||||
onShareToApp={onShareToApp}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
}
|
||||
onBackButtonPress={toggleSendModal}
|
||||
onBackdropPress={toggleSendModal}
|
||||
/>
|
||||
<BottomModal
|
||||
isVisible={isQRModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.367}
|
||||
style={{marginHorizontal: spacing.extraSmall}}
|
||||
ContentComponent={
|
||||
<SendAsQRCodeBlock
|
||||
toggleQRModal={toggleQRModal}
|
||||
encodedTokenToSend={encodedTokenToSend as string}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
}
|
||||
onBackButtonPress={toggleQRModal}
|
||||
onBackdropPress={toggleQRModal}
|
||||
/>
|
||||
<BottomModal
|
||||
isVisible={isResultModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.6}
|
||||
ContentComponent={
|
||||
<>
|
||||
{resultModalInfo &&
|
||||
transactionStatus === TransactionStatus.COMPLETED && (
|
||||
<>
|
||||
<ResultModalInfo
|
||||
icon="faCheckCircle"
|
||||
iconColor={colors.palette.success200}
|
||||
title="Success!"
|
||||
message={resultModalInfo?.message}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={() => navigation.navigate('Wallet')}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{resultModalInfo &&
|
||||
transactionStatus === TransactionStatus.ERROR && (
|
||||
<>
|
||||
<ResultModalInfo
|
||||
icon="faTriangleExclamation"
|
||||
iconColor={colors.palette.angry500}
|
||||
title="Send failed"
|
||||
message={resultModalInfo?.message}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={toggleResultModal}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onBackButtonPress={toggleResultModal}
|
||||
onBackdropPress={toggleResultModal}
|
||||
/>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const MintBalanceSelector = observer(function (props: {
|
||||
availableMintBalances: MintBalance[]
|
||||
mintBalanceToSendFrom: MintBalance
|
||||
onMintBalanceSelect: any
|
||||
onCancel: any
|
||||
findByUrl: any
|
||||
onMintBalanceConfirm: any
|
||||
}) {
|
||||
|
||||
const onMintSelect = function (balance: MintBalance) {
|
||||
log.trace('onMintBalanceSelect', balance.mint)
|
||||
return props.onMintBalanceSelect(balance)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
style={$card}
|
||||
heading={'Pay from'}
|
||||
headingStyle={{textAlign: 'center', padding: spacing.small}}
|
||||
ContentComponent={
|
||||
<>
|
||||
{props.availableMintBalances.map(
|
||||
(balance: MintBalance, index: number) => (
|
||||
<MintListItem
|
||||
key={balance.mint}
|
||||
mint={props.findByUrl(balance.mint)}
|
||||
mintBalance={balance}
|
||||
onMintSelect={() => onMintSelect(balance)}
|
||||
isSelectable={true}
|
||||
isSelected={props.mintBalanceToSendFrom.mint === balance.mint}
|
||||
separator={'top'}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<View style={[$buttonContainer, {marginTop: spacing.large}]}>
|
||||
<Button
|
||||
text="Send now"
|
||||
onPress={props.onMintBalanceConfirm}
|
||||
style={{marginRight: spacing.medium}}
|
||||
// LeftAccessory={() => <Icon icon="faCoins" color="white" size={spacing.medium} containerStyle={{marginRight: spacing.small}}/>}
|
||||
/>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.cancel'}
|
||||
onPress={props.onCancel}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const SendAsTextBlock = observer(function (props: {
|
||||
toggleSendModal: any
|
||||
encodedTokenToSend: string
|
||||
onShareToApp: any
|
||||
onCopy: any
|
||||
}) {
|
||||
const sendBg = useThemeColor('background')
|
||||
const tokenTextColor = useThemeColor('textDim')
|
||||
const $bottomContainerInsets = useSafeAreaInsetsStyle(['bottom'])
|
||||
|
||||
return (
|
||||
<View style={$bottomModal}>
|
||||
<Text
|
||||
text={'Share coins'}
|
||||
/>
|
||||
<ScrollView
|
||||
style={[
|
||||
$tokenContainer,
|
||||
{backgroundColor: sendBg, marginHorizontal: spacing.small},
|
||||
]}>
|
||||
<Text
|
||||
selectable
|
||||
text={props.encodedTokenToSend}
|
||||
style={{color: tokenTextColor, paddingBottom: spacing.medium}}
|
||||
size="xxs"
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
text="Share"
|
||||
onPress={props.onShareToApp}
|
||||
style={{marginRight: spacing.medium}}
|
||||
LeftAccessory={() => (
|
||||
<Icon
|
||||
icon="faShareFromSquare"
|
||||
color="white"
|
||||
size={spacing.medium}
|
||||
containerStyle={{marginRight: spacing.small}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button preset="secondary" text="Copy" onPress={props.onCopy} />
|
||||
<Button
|
||||
preset="tertiary"
|
||||
text="Close"
|
||||
onPress={props.toggleSendModal}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const SendAsQRCodeBlock = observer(function (props: {
|
||||
toggleQRModal: any
|
||||
encodedTokenToSend: string
|
||||
onCopy: any
|
||||
}) {
|
||||
|
||||
const sendBg = useThemeColor('background')
|
||||
const tokenTextColor = useThemeColor('textDim')
|
||||
const $bottomContainerInsets = useSafeAreaInsetsStyle(['bottom'])
|
||||
|
||||
return (
|
||||
<View style={[$bottomModal, {marginHorizontal: spacing.small}]}>
|
||||
<Text text={'Scan to receive'} />
|
||||
<View style={$qrCodeContainer}>
|
||||
<QRCode size={270} value={props.encodedTokenToSend} />
|
||||
</View>
|
||||
<View style={$buttonContainer}>
|
||||
<Button preset="secondary" text="Close" onPress={props.toggleQRModal} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
height: spacing.screenHeight * 0.18,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $amountContainer: ViewStyle = {
|
||||
height: 90,
|
||||
alignSelf: 'center',
|
||||
}
|
||||
|
||||
const $amountInput: TextStyle = {
|
||||
flex: 1,
|
||||
borderRadius: spacing.small,
|
||||
fontSize: 52,
|
||||
fontWeight: '400',
|
||||
textAlignVertical: 'center',
|
||||
textAlign: 'center',
|
||||
color: 'white',
|
||||
}
|
||||
|
||||
const $memoCard: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
}
|
||||
|
||||
const $memoContainer: ViewStyle = {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const $memoInput: TextStyle = {
|
||||
flex: 1,
|
||||
borderRadius: spacing.small,
|
||||
fontSize: 16,
|
||||
textAlignVertical: 'center',
|
||||
marginRight: spacing.small,
|
||||
}
|
||||
|
||||
const $tokenContainer: ViewStyle = {
|
||||
borderRadius: spacing.small,
|
||||
alignSelf: 'stretch',
|
||||
padding: spacing.small,
|
||||
maxHeight: 150,
|
||||
marginTop: spacing.small,
|
||||
marginBottom: spacing.large,
|
||||
}
|
||||
|
||||
const $memoButton: ViewStyle = {
|
||||
maxHeight: 50,
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
const $bottomModal: ViewStyle = {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.large,
|
||||
// paddingHorizontal: spacing.small,
|
||||
}
|
||||
|
||||
const $qrCodeContainer: ViewStyle = {
|
||||
backgroundColor: 'white',
|
||||
padding: spacing.small,
|
||||
margin: spacing.small,
|
||||
}
|
||||
|
||||
const $buttonContainer: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'center',
|
||||
}
|
||||
|
||||
const $receiveMsg: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
borderRadius: spacing.large,
|
||||
justifyContent: 'flex-start',
|
||||
padding: spacing.small,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC} from 'react'
|
||||
import {TextStyle, View, ViewStyle} from 'react-native'
|
||||
import {colors, spacing, useThemeColor} from '../theme'
|
||||
import {SettingsStackScreenProps} from '../navigation' // @demo remove-current-line
|
||||
import {Icon, ListItem, Screen, Text, Card} from '../components'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {useStores} from '../models'
|
||||
import {translate} from '../i18n'
|
||||
|
||||
interface SettingsScreenProps extends SettingsStackScreenProps<'Settings'> {}
|
||||
|
||||
export const SettingsScreen: FC<SettingsScreenProps> = observer(
|
||||
function SettingsScreen(_props) {
|
||||
const {navigation} = _props
|
||||
useHeader({}) // default header component
|
||||
const {mintsStore} = useStores()
|
||||
|
||||
function gotoMints() {
|
||||
navigation.navigate('Mints')
|
||||
}
|
||||
|
||||
function gotoSecurity() {
|
||||
navigation.navigate('Security')
|
||||
}
|
||||
|
||||
function gotoDevOptions() {
|
||||
navigation.navigate('Developer')
|
||||
}
|
||||
|
||||
function gotoBackupRestore() {
|
||||
navigation.navigate('Backup')
|
||||
}
|
||||
|
||||
const $itemRight = {color: useThemeColor('textDim')}
|
||||
const headerBg = useThemeColor('header')
|
||||
|
||||
return (
|
||||
<Screen style={$screen}>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text
|
||||
preset="heading"
|
||||
tx="settingsScreen.title"
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$card}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
tx="settingsScreen.manageMints"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faCoins"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.iconBlue300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
RightComponent={
|
||||
<View style={$rightContainer}>
|
||||
<Text
|
||||
style={$itemRight}
|
||||
text={translate('settingsScreen.mintsCount', {count: mintsStore.mintCount})}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={gotoMints}
|
||||
/>
|
||||
<ListItem
|
||||
tx="settingsScreen.backupRecovery"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faCloudArrowUp"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.success300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={gotoBackupRestore}
|
||||
/>
|
||||
<ListItem
|
||||
tx="settingsScreen.security"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faShieldHalved"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.iconGreyBlue400}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={gotoSecurity}
|
||||
/>
|
||||
<ListItem
|
||||
tx="settingsScreen.devOptions"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faCode"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.accent300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
// bottomSeparator={true}
|
||||
onPress={gotoDevOptions}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</Screen>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
height: spacing.screenHeight * 0.1,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
// flex: 1,
|
||||
padding: spacing.extraSmall,
|
||||
// alignItems: 'center',
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
// marginVertical: 0,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
// paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
const $rightContainer: ViewStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
marginLeft: spacing.small,
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useEffect, useState, useRef} from 'react'
|
||||
import {
|
||||
UIManager,
|
||||
Platform,
|
||||
Alert,
|
||||
TextInput,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
LayoutAnimation,
|
||||
ScrollView,
|
||||
Share,
|
||||
} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import QRCode from 'react-native-qrcode-svg'
|
||||
import {spacing, useThemeColor, colors} from '../theme'
|
||||
import {WalletStackScreenProps} from '../navigation'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Card,
|
||||
Screen,
|
||||
Loading,
|
||||
InfoModal,
|
||||
ErrorModal,
|
||||
ListItem,
|
||||
BottomModal,
|
||||
Text,
|
||||
} from '../components'
|
||||
import {TransactionStatus, Transaction} from '../models/Transaction'
|
||||
import {useStores} from '../models'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {Wallet} from '../services'
|
||||
import {log} from '../utils/logger'
|
||||
import AppError from '../utils/AppError'
|
||||
|
||||
import {MintBalance} from '../models/Mint'
|
||||
import {MintListItem} from './Mints/MintListItem'
|
||||
import EventEmitter from '../utils/eventEmitter'
|
||||
import {ResultModalInfo} from './Wallet/ResultModalInfo'
|
||||
import {Invoice} from '../models/Invoice'
|
||||
|
||||
if (
|
||||
Platform.OS === 'android' &&
|
||||
UIManager.setLayoutAnimationEnabledExperimental
|
||||
) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true)
|
||||
}
|
||||
|
||||
export const TopupScreen: FC<WalletStackScreenProps<'Topup'>> = observer(
|
||||
function TopupScreen({navigation}) {
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const {proofsStore, mintsStore} = useStores()
|
||||
const amountInputRef = useRef<TextInput>(null)
|
||||
const memoInputRef = useRef<TextInput>(null)
|
||||
// const tokenInputRef = useRef<TextInput>(null)
|
||||
|
||||
const [amountToTopup, setAmountToTopup] = useState<string>('')
|
||||
const [memo, setMemo] = useState('')
|
||||
const [availableMintBalances, setAvailableMintBalances] = useState<
|
||||
MintBalance[]
|
||||
>([])
|
||||
const [mintBalanceToTopup, setMintBalanceToTopup] = useState<
|
||||
MintBalance | undefined
|
||||
>()
|
||||
const [transactionStatus, setTransactionStatus] = useState<
|
||||
TransactionStatus | undefined
|
||||
>()
|
||||
const [transactionId, setTransactionId] = useState<number | undefined>()
|
||||
const [invoiceToPay, setInvoiceToPay] = useState<string>('')
|
||||
const [info, setInfo] = useState('')
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isAmountEndEditing, setIsAmountEndEditing] = useState<boolean>(false)
|
||||
const [resultModalInfo, setResultModalInfo] = useState<
|
||||
{status: TransactionStatus; message: string} | undefined
|
||||
>()
|
||||
const [isSharedAsText, setIsSharedAsText] = useState<boolean>(false)
|
||||
const [isSharedAsQRCode, setIsSharedAsQRCode] = useState<boolean>(false)
|
||||
|
||||
const [isMemoEndEditing, setIsMemoEndEditing] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [isMintSelectorVisible, setIsMintSelectorVisible] = useState(false)
|
||||
const [isShareModalVisible, setIsShareModalVisible] = useState(false)
|
||||
const [isQRModalVisible, setIsQRModalVisible] = useState(false)
|
||||
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const focus = () => {
|
||||
amountInputRef && amountInputRef.current
|
||||
? amountInputRef.current.focus()
|
||||
: false
|
||||
}
|
||||
const timer = setTimeout(() => focus(), 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleCompleted = (invoice: Invoice) => {
|
||||
log.trace('handleCompleted event handler trigerred')
|
||||
|
||||
if (!transactionId) {
|
||||
return
|
||||
}
|
||||
// Filter and handle events only for this topup transactionId
|
||||
if (invoice.transactionId === transactionId) {
|
||||
log.trace('Invoice has been paid and new proofs received')
|
||||
|
||||
setResultModalInfo({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
message: `Payment received! Your wallet has been credited with ${invoice.amount} sats.`,
|
||||
})
|
||||
|
||||
setTransactionStatus(TransactionStatus.COMPLETED)
|
||||
setIsQRModalVisible(false)
|
||||
setIsShareModalVisible(false)
|
||||
setIsResultModalVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to the 'tokenEntryAdded' event
|
||||
EventEmitter.on('topupCompleted', handleCompleted)
|
||||
|
||||
// Unsubscribe from the 'tokenEntryAdded' event on component unmount
|
||||
return () => {
|
||||
EventEmitter.off('topupCompleted', handleCompleted)
|
||||
}
|
||||
}, [transactionId])
|
||||
|
||||
const toggleShareModal = () =>
|
||||
setIsShareModalVisible(previousState => !previousState)
|
||||
const toggleQRModal = () =>
|
||||
setIsQRModalVisible(previousState => !previousState)
|
||||
const toggleResultModal = () =>
|
||||
setIsResultModalVisible(previousState => !previousState)
|
||||
|
||||
const onAmountEndEditing = function () {
|
||||
try {
|
||||
const amount = parseInt(amountToTopup)
|
||||
|
||||
if (!amount || amount === 0) {
|
||||
setInfo('Amount should be positive number')
|
||||
return
|
||||
}
|
||||
|
||||
const mintBalances = proofsStore.getBalances().mintBalances
|
||||
|
||||
if (mintBalances.length === 0) {
|
||||
setInfo(
|
||||
'There is no mint connected to your wallet that you would receive your coins from. Add the mint first.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
log.trace('onAmountEndEditing() availableBalances', mintBalances.length)
|
||||
|
||||
setAvailableMintBalances(mintBalances)
|
||||
|
||||
// Set mint to send from immediately if only one is available
|
||||
if (mintBalances.length === 1) {
|
||||
setMintBalanceToTopup(mintBalances[0])
|
||||
}
|
||||
|
||||
setIsAmountEndEditing(true)
|
||||
|
||||
memoInputRef && memoInputRef.current
|
||||
? memoInputRef.current.focus()
|
||||
: false
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const onMemoEndEditing = function () {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setIsMemoEndEditing(true)
|
||||
}
|
||||
|
||||
const onMemoDone = function () {
|
||||
if (parseInt(amountToTopup) > 0) {
|
||||
memoInputRef && memoInputRef.current
|
||||
? memoInputRef.current.blur()
|
||||
: false
|
||||
amountInputRef && amountInputRef.current
|
||||
? amountInputRef.current.blur()
|
||||
: false
|
||||
onMemoEndEditing()
|
||||
} else {
|
||||
amountInputRef && amountInputRef.current
|
||||
? amountInputRef.current.focus()
|
||||
: false
|
||||
}
|
||||
}
|
||||
const onShareAsText = async function () {
|
||||
// if tx has been already executed, re-open SendModal
|
||||
if (transactionStatus === TransactionStatus.PENDING) {
|
||||
toggleShareModal() // open
|
||||
return
|
||||
}
|
||||
|
||||
setIsSharedAsText(true)
|
||||
setIsSharedAsQRCode(false)
|
||||
// pass share kind directly to avoid delayed state update
|
||||
return onShare('TEXT')
|
||||
}
|
||||
|
||||
const onShareAsQRCode = async function () {
|
||||
// if tx has been already executed, re-open QRCodeModal
|
||||
if (transactionStatus === TransactionStatus.PENDING) {
|
||||
toggleQRModal() // open
|
||||
return
|
||||
}
|
||||
|
||||
setIsSharedAsQRCode(true)
|
||||
setIsSharedAsText(false)
|
||||
// pass share kind directly to avoid delayed state update
|
||||
return onShare('QRCODE')
|
||||
}
|
||||
|
||||
const onShare = async function (as: 'TEXT' | 'QRCODE'): Promise<void> {
|
||||
if (amountToTopup.length === 0) {
|
||||
setInfo('Provide the top-up amount')
|
||||
return
|
||||
}
|
||||
|
||||
// Skip mint modal and send immediately if only one mint is available
|
||||
if (availableMintBalances.length === 1) {
|
||||
const result = await requestTopup()
|
||||
|
||||
if (result.error) {
|
||||
setResultModalInfo({
|
||||
status: result.transaction?.status as TransactionStatus,
|
||||
message: result.error.message,
|
||||
})
|
||||
setIsResultModalVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (as === 'TEXT') {
|
||||
toggleShareModal()
|
||||
}
|
||||
if (as === 'QRCODE') {
|
||||
toggleQRModal()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-select mint with highest balance and show mint modal to confirm which mint to send from
|
||||
setMintBalanceToTopup(availableMintBalances[0])
|
||||
setIsMintSelectorVisible(true)
|
||||
// toggleMintModal() // open
|
||||
}
|
||||
|
||||
const onMintBalanceSelect = function (balance: MintBalance) {
|
||||
setMintBalanceToTopup(balance)
|
||||
}
|
||||
|
||||
const onMintBalanceConfirm = async function () {
|
||||
if (mintBalanceToTopup) {
|
||||
const result = await requestTopup()
|
||||
|
||||
if (result.error) {
|
||||
setResultModalInfo({
|
||||
status: result.transaction?.status as TransactionStatus,
|
||||
message: result.error.message,
|
||||
})
|
||||
setIsResultModalVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
isSharedAsText && toggleShareModal() // open
|
||||
isSharedAsQRCode && toggleQRModal() // open
|
||||
}
|
||||
setIsMintSelectorVisible(false)
|
||||
}
|
||||
|
||||
const onMintBalanceCancel = async function () {
|
||||
setIsMintSelectorVisible(false)
|
||||
}
|
||||
|
||||
const requestTopup = async function () {
|
||||
setIsLoading(true)
|
||||
|
||||
const result = await Wallet.topup(
|
||||
mintBalanceToTopup as MintBalance,
|
||||
parseInt(amountToTopup),
|
||||
memo,
|
||||
)
|
||||
|
||||
const {status, id} = result.transaction as Transaction
|
||||
setTransactionStatus(status)
|
||||
setTransactionId(id)
|
||||
|
||||
if (result.encodedInvoice) {
|
||||
setInvoiceToPay(result.encodedInvoice)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
return result
|
||||
}
|
||||
|
||||
const onShareToApp = async () => {
|
||||
try {
|
||||
const result = await Share.share({
|
||||
message: invoiceToPay as string,
|
||||
})
|
||||
|
||||
if (result.action === Share.sharedAction) {
|
||||
toggleShareModal()
|
||||
setTimeout(
|
||||
() =>
|
||||
setInfo(
|
||||
'Lightning invoice has been shared, waiting to be paid by receiver',
|
||||
),
|
||||
500,
|
||||
)
|
||||
} else if (result.action === Share.dismissedAction) {
|
||||
setInfo('Sharing cancelled')
|
||||
}
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const onCopy = function () {
|
||||
try {
|
||||
Clipboard.setString(invoiceToPay as string)
|
||||
} catch (e: any) {
|
||||
setInfo(`Could not copy: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = function (e: AppError): void {
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
// const inputBg = useThemeColor('background')
|
||||
|
||||
return (
|
||||
<Screen preset="auto" contentContainerStyle={$screen}>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text
|
||||
preset="subheading"
|
||||
text="Top-up amount"
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
<View style={$amountContainer}>
|
||||
<TextInput
|
||||
ref={amountInputRef}
|
||||
onChangeText={amount => setAmountToTopup(amount)}
|
||||
// onFocus={() => setIsAmountEndEditing(false)}
|
||||
onEndEditing={onAmountEndEditing}
|
||||
value={amountToTopup}
|
||||
style={$amountInput}
|
||||
maxLength={9}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus={true}
|
||||
editable={
|
||||
transactionStatus === TransactionStatus.PENDING ? false : true
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$memoCard}
|
||||
ContentComponent={
|
||||
<View style={$memoContainer}>
|
||||
<TextInput
|
||||
ref={memoInputRef}
|
||||
onChangeText={memo => setMemo(memo)}
|
||||
onEndEditing={onMemoEndEditing}
|
||||
value={`${memo}`}
|
||||
style={$memoInput}
|
||||
maxLength={200}
|
||||
keyboardType="default"
|
||||
selectTextOnFocus={true}
|
||||
placeholder="Memo for the payer"
|
||||
editable={
|
||||
transactionStatus === TransactionStatus.PENDING
|
||||
? false
|
||||
: true
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
preset="secondary"
|
||||
style={$memoButton}
|
||||
text="Done"
|
||||
onPress={onMemoDone}
|
||||
disabled={
|
||||
transactionStatus === TransactionStatus.PENDING
|
||||
? false
|
||||
: true
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
{isAmountEndEditing && isMemoEndEditing && !isMintSelectorVisible && (
|
||||
<Card
|
||||
style={$card}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
tx="topupScreen.sendInvoiceToContact"
|
||||
subTx="topupScreen.sendInvoiceToContactDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faAddressCard"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.secondary300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={() => Alert.alert('Not implemented yet')}
|
||||
/>
|
||||
<ListItem
|
||||
tx="topupScreen.showInvoiceQRCode"
|
||||
subTx="topupScreen.showInvoiceQRCodeDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faQrcode"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.success200}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={onShareAsQRCode}
|
||||
/>
|
||||
<ListItem
|
||||
tx="topupScreen.shareInvoiceAsText"
|
||||
subTx="topupScreen.shareInvoiceAsTextDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faShareFromSquare"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.accent300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
onPress={onShareAsText}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isMintSelectorVisible &&
|
||||
transactionStatus !== TransactionStatus.PENDING && (
|
||||
<MintBalanceSelector
|
||||
availableMintBalances={availableMintBalances}
|
||||
mintBalanceToTopup={mintBalanceToTopup as MintBalance}
|
||||
onMintBalanceSelect={onMintBalanceSelect}
|
||||
onCancel={onMintBalanceCancel}
|
||||
findByUrl={mintsStore.findByUrl}
|
||||
onMintBalanceConfirm={onMintBalanceConfirm}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
<BottomModal
|
||||
isVisible={isShareModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.367}
|
||||
style={{marginHorizontal: spacing.extraSmall}}
|
||||
ContentComponent={
|
||||
<ShareAsTextBlock
|
||||
toggleSendModal={toggleShareModal}
|
||||
invoiceToPay={invoiceToPay as string}
|
||||
onShareToApp={onShareToApp}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
}
|
||||
onBackButtonPress={toggleShareModal}
|
||||
onBackdropPress={toggleShareModal}
|
||||
/>
|
||||
<BottomModal
|
||||
isVisible={isQRModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.367}
|
||||
style={{marginHorizontal: spacing.extraSmall}}
|
||||
ContentComponent={
|
||||
<ShareAsQRCodeBlock
|
||||
toggleQRModal={toggleQRModal}
|
||||
encodedTokenToSend={invoiceToPay as string}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
}
|
||||
onBackButtonPress={toggleQRModal}
|
||||
onBackdropPress={toggleQRModal}
|
||||
/>
|
||||
<BottomModal
|
||||
isVisible={isResultModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.6}
|
||||
style={{paddingHorizontal: spacing.small}}
|
||||
ContentComponent={
|
||||
<>
|
||||
{resultModalInfo &&
|
||||
transactionStatus === TransactionStatus.COMPLETED && (
|
||||
<>
|
||||
<ResultModalInfo
|
||||
icon="faCheckCircle"
|
||||
iconColor={colors.palette.success200}
|
||||
title="Success!"
|
||||
message={resultModalInfo?.message}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={() => navigation.navigate('Wallet')}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{resultModalInfo &&
|
||||
transactionStatus === TransactionStatus.ERROR && (
|
||||
<>
|
||||
<ResultModalInfo
|
||||
icon="faTriangleExclamation"
|
||||
iconColor={colors.palette.angry500}
|
||||
title="Topup failed"
|
||||
message={resultModalInfo?.message}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={toggleResultModal}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onBackButtonPress={toggleResultModal}
|
||||
onBackdropPress={toggleResultModal}
|
||||
/>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const MintBalanceSelector = observer(function (props: {
|
||||
availableMintBalances: MintBalance[]
|
||||
mintBalanceToTopup: MintBalance
|
||||
onMintBalanceSelect: any
|
||||
onCancel: any
|
||||
findByUrl: any
|
||||
onMintBalanceConfirm: any
|
||||
}) {
|
||||
const onMintSelect = function (balance: MintBalance) {
|
||||
log.trace('onMintBalanceSelect', balance.mint)
|
||||
return props.onMintBalanceSelect(balance)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
style={$card}
|
||||
heading={'Select mint to top-up'}
|
||||
headingStyle={{textAlign: 'center', padding: spacing.small}}
|
||||
ContentComponent={
|
||||
<>
|
||||
{props.availableMintBalances.map(
|
||||
(balance: MintBalance) => (
|
||||
<MintListItem
|
||||
key={balance.mint}
|
||||
mint={props.findByUrl(balance.mint)}
|
||||
mintBalance={balance}
|
||||
onMintSelect={() => onMintSelect(balance)}
|
||||
isSelectable={true}
|
||||
isSelected={props.mintBalanceToTopup.mint === balance.mint}
|
||||
separator={'top'}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<View style={[$buttonContainer, {marginTop: spacing.large}]}>
|
||||
<Button
|
||||
text="Create invoice"
|
||||
onPress={props.onMintBalanceConfirm}
|
||||
style={{marginRight: spacing.medium}}
|
||||
// LeftAccessory={() => <Icon icon="faCoins" color="white" size={spacing.medium} containerStyle={{marginRight: spacing.small}}/>}
|
||||
/>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.cancel'}
|
||||
onPress={props.onCancel}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const ShareAsTextBlock = observer(function (props: {
|
||||
toggleSendModal: any
|
||||
invoiceToPay: string
|
||||
onShareToApp: any
|
||||
onCopy: any
|
||||
}) {
|
||||
const sendBg = useThemeColor('background')
|
||||
const tokenTextColor = useThemeColor('textDim')
|
||||
|
||||
return (
|
||||
<View style={$bottomModal}>
|
||||
<Text text={'Share lightning invoice'} />
|
||||
<ScrollView
|
||||
style={[
|
||||
$tokenContainer,
|
||||
{backgroundColor: sendBg, marginHorizontal: spacing.small},
|
||||
]}>
|
||||
<Text
|
||||
selectable
|
||||
text={props.invoiceToPay}
|
||||
style={{color: tokenTextColor, paddingBottom: spacing.medium}}
|
||||
size="xxs"
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
text="Share"
|
||||
onPress={props.onShareToApp}
|
||||
style={{marginRight: spacing.medium}}
|
||||
LeftAccessory={() => (
|
||||
<Icon
|
||||
icon="faShareFromSquare"
|
||||
color="white"
|
||||
size={spacing.medium}
|
||||
containerStyle={{marginRight: spacing.small}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button preset="secondary" text="Copy" onPress={props.onCopy} />
|
||||
<Button
|
||||
preset="tertiary"
|
||||
text="Close"
|
||||
onPress={props.toggleSendModal}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const ShareAsQRCodeBlock = observer(function (props: {
|
||||
toggleQRModal: any
|
||||
encodedTokenToSend: string
|
||||
onCopy: any
|
||||
}) {
|
||||
return (
|
||||
<View style={[$bottomModal, {marginHorizontal: spacing.small}]}>
|
||||
<Text text={'Scan and pay to top-up'} />
|
||||
<View style={$qrCodeContainer}>
|
||||
<QRCode size={270} value={props.encodedTokenToSend} />
|
||||
</View>
|
||||
<View style={$buttonContainer}>
|
||||
<Button preset="secondary" text="Close" onPress={props.toggleQRModal} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
height: spacing.screenHeight * 0.18,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $amountContainer: ViewStyle = {
|
||||
height: 90,
|
||||
alignSelf: 'center',
|
||||
}
|
||||
|
||||
const $amountInput: TextStyle = {
|
||||
flex: 1,
|
||||
borderRadius: spacing.small,
|
||||
fontSize: 52,
|
||||
fontWeight: '400',
|
||||
textAlignVertical: 'center',
|
||||
textAlign: 'center',
|
||||
color: 'white',
|
||||
}
|
||||
|
||||
const $memoCard: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
}
|
||||
|
||||
const $memoContainer: ViewStyle = {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const $memoInput: TextStyle = {
|
||||
flex: 1,
|
||||
borderRadius: spacing.small,
|
||||
fontSize: 16,
|
||||
textAlignVertical: 'center',
|
||||
marginRight: spacing.small,
|
||||
}
|
||||
|
||||
const $tokenContainer: ViewStyle = {
|
||||
borderRadius: spacing.small,
|
||||
alignSelf: 'stretch',
|
||||
padding: spacing.small,
|
||||
maxHeight: 150,
|
||||
marginTop: spacing.small,
|
||||
marginBottom: spacing.large,
|
||||
}
|
||||
|
||||
const $memoButton: ViewStyle = {
|
||||
maxHeight: 50,
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
const $bottomModal: ViewStyle = {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.large,
|
||||
// paddingHorizontal: spacing.small,
|
||||
}
|
||||
|
||||
const $qrCodeContainer: ViewStyle = {
|
||||
backgroundColor: 'white',
|
||||
padding: spacing.small,
|
||||
margin: spacing.small,
|
||||
}
|
||||
|
||||
const $buttonContainer: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'center',
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useEffect, useRef, useState} from 'react'
|
||||
import {
|
||||
Alert,
|
||||
TextInput,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import JSONTree from 'react-native-json-tree'
|
||||
import {colors, spacing, useThemeColor} from '../theme'
|
||||
import {WalletStackScreenProps} from '../navigation'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
ListItem,
|
||||
Screen,
|
||||
Text,
|
||||
Card,
|
||||
ErrorModal,
|
||||
BottomModal,
|
||||
InfoModal,
|
||||
} from '../components'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {useStores} from '../models'
|
||||
import {translate, TxKeyPath} from '../i18n'
|
||||
import {
|
||||
Transaction,
|
||||
TransactionStatus,
|
||||
TransactionType,
|
||||
} from '../models/Transaction'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {log} from '../utils/logger'
|
||||
import {getMintsFromToken} from '../services/cashuHelpers'
|
||||
import {Token} from '../models/Token'
|
||||
import {isArray} from 'lodash'
|
||||
import {Database} from '../services'
|
||||
import {BackupProof, Proof} from '../models/Proof'
|
||||
|
||||
type ProofsByStatus = {
|
||||
isSpent: Proof[]
|
||||
isPending: Proof[]
|
||||
isReceived: Proof[]
|
||||
}
|
||||
|
||||
export const TranDetailScreen: FC<WalletStackScreenProps<'TranDetail'>> =
|
||||
observer(function TranDetailScreen(_props) {
|
||||
const {navigation, route} = _props
|
||||
const {transactionsStore, userSettingsStore} = useStores()
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const noteInputRef = useRef<TextInput>(null)
|
||||
|
||||
const [transaction, setTransaction] = useState<Transaction>()
|
||||
const [proofsByStatus, setProofsByStatus] = useState<
|
||||
ProofsByStatus | undefined
|
||||
>(undefined)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isNoteModalVisible, setIsNoteModalVisible] = useState<boolean>(false)
|
||||
const [isDataParsable, setIsDataParsable] = useState<boolean>(true)
|
||||
const [info, setInfo] = useState('')
|
||||
const [note, setNote] = useState<string>('')
|
||||
const [savedNote, setSavedNote] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const {id} = route.params
|
||||
const tx = transactionsStore.findById(id)
|
||||
|
||||
if (!tx) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Could not retrieve transaction from transactionsStore',
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(tx.data)
|
||||
} catch (e: any) {
|
||||
setIsDataParsable(false)
|
||||
}
|
||||
|
||||
|
||||
setTransaction(tx)
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const {id} = route.params
|
||||
|
||||
if (userSettingsStore.isLocalBackupOn === true) {
|
||||
const proofs = Database.getProofsByTransaction(id)
|
||||
|
||||
if (proofs.length > 0) {
|
||||
const proofsByStatus = proofs.reduce(
|
||||
(result: ProofsByStatus, proof: BackupProof) => {
|
||||
if (proof.isSpent) {
|
||||
result.isSpent.push(proof)
|
||||
} else if (proof.isPending) {
|
||||
result.isPending.push(proof)
|
||||
} else {
|
||||
result.isReceived.push(proof)
|
||||
}
|
||||
return result
|
||||
},
|
||||
{isReceived: [], isPending: [], isSpent: []},
|
||||
)
|
||||
|
||||
log.trace(proofsByStatus)
|
||||
|
||||
setProofsByStatus(proofsByStatus)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error(e.name, e.message)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const focus = () => {
|
||||
noteInputRef && noteInputRef.current
|
||||
? noteInputRef.current.focus()
|
||||
: false
|
||||
}
|
||||
|
||||
if (isNoteModalVisible) {
|
||||
setTimeout(() => focus(), 100)
|
||||
}
|
||||
}, [isNoteModalVisible])
|
||||
|
||||
|
||||
|
||||
const toggleNoteModal = function () {
|
||||
if (isNoteModalVisible) {
|
||||
setIsNoteModalVisible(false)
|
||||
} else {
|
||||
setIsNoteModalVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
const saveNote = async function () {
|
||||
try {
|
||||
setIsNoteModalVisible(false)
|
||||
await transactionsStore.saveNote(transaction?.id as number, note)
|
||||
setSavedNote(note)
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const copyAuditTrail = function (transaction: Transaction) {
|
||||
try {
|
||||
Clipboard.setString(JSON.stringify(getAuditTrail(transaction)))
|
||||
} catch (e: any) {
|
||||
setInfo(`Could not copy: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToken = function (transaction: Transaction) {
|
||||
try {
|
||||
const encoded = getEncodedTokenToSend(transaction)
|
||||
|
||||
if (!encoded) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Could not get encoded coins from transaction',
|
||||
)
|
||||
}
|
||||
|
||||
Clipboard.setString(encoded)
|
||||
|
||||
} catch (e: any) {
|
||||
setInfo(`Could not copy: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = function (e: AppError): void {
|
||||
setIsNoteModalVisible(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
const iconColor = useThemeColor('textDim')
|
||||
const labelColor = useThemeColor('textDim')
|
||||
const inputBg = useThemeColor('background')
|
||||
const tokenTextColor = useThemeColor('textDim')
|
||||
|
||||
|
||||
|
||||
const getFormattedAmount = function(amount: number): string {
|
||||
if (!transaction) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (transaction?.type) {
|
||||
case TransactionType.RECEIVE || TransactionType.RECEIVE_NOSTR:
|
||||
return `+${transaction.amount}`
|
||||
case TransactionType.SEND:
|
||||
return `-${transaction.amount}`
|
||||
case TransactionType.TOPUP:
|
||||
return `+${transaction.amount}`
|
||||
case TransactionType.TRANSFER:
|
||||
return `-${transaction.amount}`
|
||||
default:
|
||||
return `${transaction?.amount}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Screen style={$screen} preset="auto">
|
||||
{transaction && (
|
||||
<>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
{transaction && (
|
||||
<Text
|
||||
preset="heading"
|
||||
text={getFormattedAmount(transaction.amount)}
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$actionCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
text={
|
||||
transaction.noteToSelf
|
||||
? transaction.noteToSelf
|
||||
: savedNote
|
||||
? savedNote
|
||||
: 'Add your note'
|
||||
}
|
||||
LeftComponent={
|
||||
<Icon
|
||||
containerStyle={$iconContainer}
|
||||
icon="faPencil"
|
||||
size={spacing.medium}
|
||||
color={iconColor}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={toggleNoteModal}
|
||||
/>
|
||||
<ListItem
|
||||
text="Add tags"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
containerStyle={$iconContainer}
|
||||
icon="faTags"
|
||||
size={spacing.medium}
|
||||
color={iconColor}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={false}
|
||||
onPress={() => Alert.alert('Not yet implemented')}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{transaction.type === TransactionType.RECEIVE && (
|
||||
<ReceiveInfoBlock
|
||||
transaction={transaction}
|
||||
isDataParsable={isDataParsable}
|
||||
copyAuditTrail={copyAuditTrail}
|
||||
/>
|
||||
)}
|
||||
{transaction.type === TransactionType.SEND && (
|
||||
<SendInfoBlock
|
||||
transaction={transaction}
|
||||
isDataParsable={isDataParsable}
|
||||
copyToken={copyToken}
|
||||
copyAuditTrail={copyAuditTrail}
|
||||
/>
|
||||
)}
|
||||
{transaction.type === TransactionType.TOPUP && (
|
||||
<TopupInfoBlock
|
||||
transaction={transaction}
|
||||
isDataParsable={isDataParsable}
|
||||
copyAuditTrail={copyAuditTrail}
|
||||
/>
|
||||
)}
|
||||
{transaction.type === TransactionType.TRANSFER && (
|
||||
<TransferInfoBlock
|
||||
transaction={transaction}
|
||||
isDataParsable={isDataParsable}
|
||||
copyAuditTrail={copyAuditTrail}
|
||||
/>
|
||||
)}
|
||||
{proofsByStatus && (
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<Text
|
||||
style={{color: labelColor, fontSize: 14}}
|
||||
text="Backed up coins"
|
||||
/>
|
||||
<JSONTree
|
||||
hideRoot
|
||||
data={proofsByStatus}
|
||||
theme={{
|
||||
scheme: 'default',
|
||||
base00: '#eee',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
FooterComponent={
|
||||
<Button
|
||||
preset="tertiary"
|
||||
onPress={() => Alert.alert('Not yet implemented')}
|
||||
text="Recover coins"
|
||||
style={{
|
||||
minHeight: 25,
|
||||
paddingVertical: spacing.extraSmall,
|
||||
marginTop: spacing.small,
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
textStyle={{fontSize: 14}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<BottomModal
|
||||
isVisible={isNoteModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.4}
|
||||
ContentComponent={
|
||||
<View style={$noteContainer}>
|
||||
<Text text="Add your note" preset="subheading" />
|
||||
<View style={{flexDirection: 'row', alignItems: 'center'}}>
|
||||
<TextInput
|
||||
ref={noteInputRef}
|
||||
onChangeText={note => setNote(note)}
|
||||
value={note}
|
||||
style={[$noteInput, {backgroundColor: inputBg}]}
|
||||
maxLength={200}
|
||||
/>
|
||||
<Button
|
||||
text="Save"
|
||||
style={{
|
||||
borderRadius: spacing.small,
|
||||
marginRight: spacing.small,
|
||||
}}
|
||||
onPress={saveNote}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
onBackButtonPress={toggleNoteModal}
|
||||
onBackdropPress={toggleNoteModal}
|
||||
/>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
})
|
||||
|
||||
const ReceiveInfoBlock = function (props: {
|
||||
transaction: Transaction
|
||||
isDataParsable: boolean
|
||||
copyAuditTrail: any
|
||||
}) {
|
||||
const {transaction, isDataParsable, copyAuditTrail} = props
|
||||
const labelColor = useThemeColor('textDim')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<TranItem
|
||||
label="tranDetailScreen.amount"
|
||||
value={`${transaction.amount}`}
|
||||
isFirst={true}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.memoFromSender"
|
||||
value={transaction.memo as string}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.sentFrom"
|
||||
value={transaction.sentFrom as string}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.type"
|
||||
value={transaction.type as string}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.status"
|
||||
value={transaction.status as string}
|
||||
/>
|
||||
{transaction.status !== TransactionStatus.ERROR && (
|
||||
<TranItem
|
||||
label="tranDetailScreen.balanceAfter"
|
||||
value={`${transaction.balanceAfter}`}
|
||||
/>
|
||||
)}
|
||||
<TranItem
|
||||
label="tranDetailScreen.createdAt"
|
||||
value={(transaction.createdAt as Date).toLocaleString()}
|
||||
/>
|
||||
<TranItem label="tranDetailScreen.id" value={`${transaction.id}`} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{isDataParsable && (
|
||||
<>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<TranItem
|
||||
label="tranDetailScreen.receivedTo"
|
||||
value={getMints(transaction).toString()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<Text
|
||||
style={{color: labelColor, fontSize: 14}}
|
||||
text="Audit trail"
|
||||
/>
|
||||
<JSONTree
|
||||
hideRoot
|
||||
data={getAuditTrail(transaction)}
|
||||
theme={{
|
||||
scheme: 'default',
|
||||
base00: '#eee',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
FooterComponent={
|
||||
<Button
|
||||
preset="tertiary"
|
||||
onPress={() => copyAuditTrail(transaction)}
|
||||
tx="common.copy"
|
||||
style={{
|
||||
minHeight: 25,
|
||||
paddingVertical: spacing.extraSmall,
|
||||
marginTop: spacing.small,
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
textStyle={{fontSize: 14}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SendInfoBlock = function (props: {
|
||||
transaction: Transaction
|
||||
isDataParsable: boolean
|
||||
copyToken: any
|
||||
copyAuditTrail: any
|
||||
}) {
|
||||
const {transaction, isDataParsable, copyToken, copyAuditTrail} = props
|
||||
const labelColor = useThemeColor('textDim')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<TranItem
|
||||
label="tranDetailScreen.amount"
|
||||
value={`${transaction.amount}`}
|
||||
isFirst={true}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.memoToReceiver"
|
||||
value={transaction.memo as string}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.type"
|
||||
value={transaction.type as string}
|
||||
/>
|
||||
{transaction.status === TransactionStatus.PENDING ? (
|
||||
<View
|
||||
style={{flexDirection: 'row', justifyContent: 'space-between'}}>
|
||||
<TranItem
|
||||
label="tranDetailScreen.status"
|
||||
value={transaction.status as string}
|
||||
/>
|
||||
<Button
|
||||
style={{maxHeight: 10, marginTop: spacing.medium}}
|
||||
preset="secondary"
|
||||
tx="tranDetailScreen.revert"
|
||||
onPress={() =>
|
||||
Alert.alert('Not yet implemented. Copy the token instead.')
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<TranItem
|
||||
label="tranDetailScreen.status"
|
||||
value={transaction.status as string}
|
||||
/>
|
||||
)}
|
||||
{transaction.status !== TransactionStatus.ERROR && (
|
||||
<TranItem
|
||||
label="tranDetailScreen.balanceAfter"
|
||||
value={`${transaction.balanceAfter}`}
|
||||
/>
|
||||
)}
|
||||
<TranItem
|
||||
label="tranDetailScreen.createdAt"
|
||||
value={(transaction.createdAt as Date).toLocaleString()}
|
||||
/>
|
||||
<TranItem label="tranDetailScreen.id" value={`${transaction.id}`} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{isDataParsable && (
|
||||
<>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<TranItem
|
||||
label="tranDetailScreen.paidFrom"
|
||||
value={
|
||||
JSON.parse(transaction.data)[0].mintBalanceToSendFrom.mint
|
||||
}
|
||||
/>
|
||||
{transaction.status === TransactionStatus.PENDING && (
|
||||
<>
|
||||
<Text
|
||||
style={{
|
||||
color: labelColor,
|
||||
fontSize: 14,
|
||||
marginTop: spacing.small,
|
||||
}}
|
||||
text="Pending token"
|
||||
/>
|
||||
<Text
|
||||
text={getEncodedTokenToSend(transaction) as string}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
FooterComponent={
|
||||
<>
|
||||
{transaction.status === TransactionStatus.PENDING && (
|
||||
<Button
|
||||
preset="tertiary"
|
||||
onPress={() => copyToken(transaction)}
|
||||
text="Copy"
|
||||
style={{
|
||||
minHeight: 25,
|
||||
paddingVertical: spacing.extraSmall,
|
||||
marginTop: spacing.small,
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
textStyle={{fontSize: 14}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<Text
|
||||
style={{color: labelColor, fontSize: 14}}
|
||||
text="Audit trail"
|
||||
/>
|
||||
<JSONTree
|
||||
hideRoot
|
||||
data={getAuditTrail(transaction)}
|
||||
theme={{
|
||||
scheme: 'default',
|
||||
base00: '#eee',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
// footerStyle={{borderWidth: 10, borderColor: 'red'}}
|
||||
FooterComponent={
|
||||
<Button
|
||||
preset="tertiary"
|
||||
onPress={() => copyAuditTrail(transaction)}
|
||||
text="Copy"
|
||||
style={{
|
||||
minHeight: 25,
|
||||
paddingVertical: spacing.extraSmall,
|
||||
marginTop: spacing.small,
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
textStyle={{fontSize: 14}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TopupInfoBlock = function (props: {
|
||||
transaction: Transaction
|
||||
isDataParsable: boolean
|
||||
copyAuditTrail: any
|
||||
}) {
|
||||
const {transaction, isDataParsable, copyAuditTrail} = props
|
||||
|
||||
const labelColor = useThemeColor('textDim')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<TranItem
|
||||
label="tranDetailScreen.amount"
|
||||
value={`${transaction.amount}`}
|
||||
isFirst={true}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.memoToReceiver"
|
||||
value={transaction.memo as string}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.status"
|
||||
value={transaction.status as string}
|
||||
/>
|
||||
{transaction.status === TransactionStatus.COMPLETED && (
|
||||
<TranItem
|
||||
label="tranDetailScreen.balanceAfter"
|
||||
value={`${transaction.balanceAfter}`}
|
||||
/>
|
||||
)}
|
||||
<TranItem
|
||||
label="tranDetailScreen.createdAt"
|
||||
value={(transaction.createdAt as Date).toLocaleString()}
|
||||
/>
|
||||
<TranItem label="tranDetailScreen.id" value={`${transaction.id}`} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{isDataParsable && (
|
||||
<>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<Text
|
||||
style={{color: labelColor, fontSize: 14}}
|
||||
text="Audit trail"
|
||||
/>
|
||||
<JSONTree
|
||||
hideRoot
|
||||
data={getAuditTrail(transaction)}
|
||||
theme={{
|
||||
scheme: 'default',
|
||||
base00: '#eee',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
FooterComponent={
|
||||
<Button
|
||||
preset="tertiary"
|
||||
onPress={() => copyAuditTrail(transaction)}
|
||||
text="Copy"
|
||||
style={{
|
||||
minHeight: 25,
|
||||
paddingVertical: spacing.extraSmall,
|
||||
marginTop: spacing.small,
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
textStyle={{fontSize: 14}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TransferInfoBlock = function (props: {
|
||||
transaction: Transaction
|
||||
isDataParsable: boolean
|
||||
copyAuditTrail: any
|
||||
}) {
|
||||
const {transaction, isDataParsable, copyAuditTrail} = props
|
||||
|
||||
const labelColor = useThemeColor('textDim')
|
||||
|
||||
// log.trace(JSON.parse(transaction.data)[0])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<TranItem
|
||||
label="tranDetailScreen.amount"
|
||||
value={`${transaction.amount}`}
|
||||
isFirst={true}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.memoFromInvoice"
|
||||
value={transaction.memo as string}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.type"
|
||||
value={transaction.type as string}
|
||||
/>
|
||||
<TranItem
|
||||
label="tranDetailScreen.status"
|
||||
value={transaction.status as string}
|
||||
/>
|
||||
|
||||
{transaction.status !== TransactionStatus.ERROR && (
|
||||
<TranItem
|
||||
label="tranDetailScreen.balanceAfter"
|
||||
value={`${transaction.balanceAfter}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TranItem
|
||||
label="tranDetailScreen.createdAt"
|
||||
value={(transaction.createdAt as Date).toLocaleString()}
|
||||
/>
|
||||
<TranItem label="tranDetailScreen.id" value={`${transaction.id}`} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{isDataParsable && (
|
||||
<>
|
||||
<Card
|
||||
style={$dataCard}
|
||||
ContentComponent={
|
||||
<TranItem
|
||||
label="tranDetailScreen.paidFrom"
|
||||
value={
|
||||
JSON.parse(transaction.data)[0].mintBalanceToTransferFrom.mint
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
style={[$dataCard]}
|
||||
ContentComponent={
|
||||
<>
|
||||
<Text
|
||||
style={{color: labelColor, fontSize: 14}}
|
||||
text="Audit trail"
|
||||
/>
|
||||
<JSONTree
|
||||
hideRoot
|
||||
data={getAuditTrail(transaction)}
|
||||
theme={{
|
||||
scheme: 'default',
|
||||
base00: '#eee',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
// footerStyle={{borderWidth: 10, borderColor: 'red'}}
|
||||
FooterComponent={
|
||||
<Button
|
||||
preset="tertiary"
|
||||
onPress={() => copyAuditTrail(transaction)}
|
||||
text="Copy"
|
||||
style={{
|
||||
minHeight: 25,
|
||||
paddingVertical: spacing.extraSmall,
|
||||
marginTop: spacing.small,
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
textStyle={{fontSize: 14}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TranItem = function (props: {
|
||||
label: TxKeyPath
|
||||
value: string
|
||||
isFirst?: boolean
|
||||
isLast?: boolean
|
||||
}) {
|
||||
|
||||
const labelColor = useThemeColor('textDim')
|
||||
const margin = !props.isFirst ? {marginTop: spacing.small} : null
|
||||
return (
|
||||
<View>
|
||||
<Text
|
||||
style={[{color: labelColor, fontSize: 14}, margin]}
|
||||
tx={props.label}
|
||||
/>
|
||||
<Text text={props.value} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const getMints = function (transaction: Transaction) {
|
||||
try {
|
||||
const mints = JSON.parse(transaction.data)[0].tokenMints || []
|
||||
return mints
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getAuditTrail = function (transaction: Transaction) {
|
||||
try {
|
||||
const data = JSON.parse(transaction.data)
|
||||
|
||||
if (data && isArray(data)) {
|
||||
return data
|
||||
/* for (const item of data) {
|
||||
if(item.status === TransactionStatus.ERROR) {
|
||||
return item
|
||||
}
|
||||
}*/
|
||||
}
|
||||
return false
|
||||
} catch (e) {
|
||||
// silent
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const getEncodedTokenToSend = (
|
||||
transaction: Transaction,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const data = JSON.parse(transaction.data)
|
||||
const pendingRecord = data.find(
|
||||
(record: any) => record.status === 'PENDING',
|
||||
)
|
||||
|
||||
if (pendingRecord) {
|
||||
return pendingRecord.encodedTokenToSend
|
||||
}
|
||||
|
||||
return undefined // No pending record found
|
||||
} catch (e) {
|
||||
// silent
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const $screen: ViewStyle = {}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
height: spacing.screenHeight * 0.18,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $actionCard: ViewStyle = {
|
||||
marginBottom: spacing.extraSmall,
|
||||
marginTop: -spacing.extraLarge * 2,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $dataCard: ViewStyle = {
|
||||
padding: spacing.medium,
|
||||
marginBottom: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
|
||||
const $iconContainer: ViewStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.medium,
|
||||
}
|
||||
|
||||
const $noteContainer: TextStyle = {
|
||||
padding: spacing.small,
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const $noteInput: TextStyle = {
|
||||
flex: 1,
|
||||
margin: spacing.small,
|
||||
borderRadius: spacing.small,
|
||||
fontSize: 16,
|
||||
padding: spacing.small,
|
||||
alignSelf: 'stretch',
|
||||
textAlignVertical: 'top',
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useState, useEffect, useRef, useMemo} from 'react'
|
||||
import {
|
||||
ImageStyle,
|
||||
TextStyle,
|
||||
ViewStyle,
|
||||
View,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from 'react-native'
|
||||
import {formatDistance, toDate} from 'date-fns'
|
||||
import {useThemeColor, spacing, colors, typography} from '../theme'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Screen,
|
||||
Text,
|
||||
Card,
|
||||
ListItem,
|
||||
ErrorModal,
|
||||
InfoModal,
|
||||
Loading,
|
||||
} from '../components'
|
||||
import {WalletStackScreenProps} from '../navigation'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {useStores} from '../models'
|
||||
import {maxTransactionsInModel} from '../models/TransactionsStore'
|
||||
import {Database} from '../services'
|
||||
import AppError from '../utils/AppError'
|
||||
import {TransactionListItem} from './Transactions/TransactionListItem'
|
||||
|
||||
interface TranHistoryScreenProps
|
||||
extends WalletStackScreenProps<'TranHistory'> {}
|
||||
|
||||
// Number of transactions held in TransactionsStore model
|
||||
const limit = maxTransactionsInModel
|
||||
|
||||
export const TranHistoryScreen: FC<TranHistoryScreenProps> = observer(function TranHistoryScreen(_props) {
|
||||
const {navigation} = _props
|
||||
const {transactionsStore, proofsStore} = useStores()
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const [showPendingOnly, setShowPendingOnly] = useState<boolean>(false)
|
||||
const [info, setInfo] = useState('')
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [offset, setOffset] = useState<number>(transactionsStore.count) // load from db those that are not already displayed
|
||||
const [isAll, setIsAll] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (transactionsStore.count < limit) {
|
||||
setIsAll(true)
|
||||
}
|
||||
// Run on component unmount (cleanup)
|
||||
return () => {
|
||||
/* When leaving screen we remove all transactions over numTransactionsInModel
|
||||
* from the transactionsStore that might have been sourced from sqlite db while browsing older records
|
||||
*/
|
||||
transactionsStore.removeOldTransactions()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getTransactionsList = async function () {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await Database.getTransactionsAsync(limit, offset)
|
||||
|
||||
if (result && result.length > 0) {
|
||||
// Add new transaction to the transactions store so mobx refreshes UI
|
||||
transactionsStore.addTransactionsToModel(result._array)
|
||||
|
||||
setOffset(offset + result.length)
|
||||
|
||||
if (result?.length < limit) {
|
||||
setIsAll(true)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleShowPendingOnly = function () {
|
||||
if (showPendingOnly) {
|
||||
setShowPendingOnly(false)
|
||||
} else {
|
||||
setShowPendingOnly(true)
|
||||
}
|
||||
}
|
||||
|
||||
const gotoTranDetail = function (id: number) {
|
||||
navigation.navigate('TranDetail', {id})
|
||||
}
|
||||
|
||||
const handleError = function (e: AppError): void {
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
const iconColor = useThemeColor('textDim')
|
||||
const activeIconColor = useThemeColor('button')
|
||||
const pendingBalance = proofsStore.getBalances().totalPendingBalance
|
||||
|
||||
return (
|
||||
<Screen style={$screen} preset="auto">
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text preset="heading" text="History" style={{color: 'white'}} />
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
<Card
|
||||
style={$actionCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
text={'Pending'}
|
||||
LeftComponent={
|
||||
<Icon
|
||||
containerStyle={$iconContainer}
|
||||
icon="faPaperPlane"
|
||||
size={spacing.medium}
|
||||
color={showPendingOnly ? activeIconColor : iconColor}
|
||||
/>
|
||||
}
|
||||
RightComponent={
|
||||
<Text style={$txAmount} text={`${pendingBalance}`} />
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={toggleShowPendingOnly}
|
||||
/>
|
||||
<ListItem
|
||||
text="Filter by tags"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
containerStyle={$iconContainer}
|
||||
icon="faTags"
|
||||
size={spacing.medium}
|
||||
color={iconColor}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={false}
|
||||
onPress={() => Alert.alert('Not implemented yet')}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{transactionsStore.count > 0 && (
|
||||
<Card
|
||||
ContentComponent={
|
||||
<>
|
||||
{(showPendingOnly
|
||||
? transactionsStore.pending
|
||||
: transactionsStore.all
|
||||
).map((tx, index: number) => (
|
||||
<TransactionListItem
|
||||
key={tx.id}
|
||||
tx={tx}
|
||||
gotoTranDetail={gotoTranDetail}
|
||||
isFirst={index === 0}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
FooterComponent={
|
||||
<View style={{alignItems: 'center'}}>
|
||||
{isAll ? (
|
||||
<Text text="List is complete" size="xs" />
|
||||
) : (
|
||||
<Button
|
||||
preset="tertiary"
|
||||
onPress={getTransactionsList}
|
||||
text="View more"
|
||||
style={{minHeight: 25, paddingVertical: spacing.tiny}}
|
||||
textStyle={{fontSize: 14}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
style={$card}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
// borderWidth: 1,
|
||||
// borderColor: 'red'
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.medium,
|
||||
height: spacing.screenHeight * 0.18,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
minHeight: spacing.screenHeight * 0.5,
|
||||
padding: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $actionCard: ViewStyle = {
|
||||
marginBottom: spacing.extraSmall,
|
||||
marginTop: -spacing.extraLarge * 2,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
|
||||
const $iconContainer: ViewStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.medium,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
marginHorizontal: spacing.micro,
|
||||
}
|
||||
|
||||
|
||||
const $txAmount: TextStyle = {
|
||||
fontFamily: typography.primary?.medium,
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.small,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import formatDistance from "date-fns/formatDistance"
|
||||
import { observer } from "mobx-react-lite"
|
||||
import { refStructEnhancer } from "mobx/dist/internal"
|
||||
import React from "react"
|
||||
import { ScrollView, TextStyle, View, ViewStyle } from "react-native"
|
||||
import { Button, Icon, ListItem, Screen, Text } from "../../components"
|
||||
import { Transaction, TransactionStatus, TransactionType } from "../../models/Transaction"
|
||||
import { colors, spacing, typography, useThemeColor } from "../../theme"
|
||||
|
||||
export interface TransactionListProps {
|
||||
transaction: Transaction
|
||||
}
|
||||
|
||||
export const TransactionListItem = observer(function (props: {tx: Transaction, isFirst: boolean, gotoTranDetail: any}) {
|
||||
|
||||
const { tx } = props
|
||||
|
||||
const txReceiveColor = colors.palette.success300
|
||||
const txSendColor = useThemeColor('amount')
|
||||
const txErrorColor = useThemeColor('textDim')
|
||||
const txPendingColor = useThemeColor('textDim')
|
||||
|
||||
const getText = function(tx: Transaction) {
|
||||
if(tx.noteToSelf) return tx.noteToSelf
|
||||
if(tx.memo) return tx.memo
|
||||
|
||||
switch(tx.type) {
|
||||
case TransactionType.RECEIVE || TransactionType.RECEIVE_NOSTR:
|
||||
return 'You received'
|
||||
case TransactionType.SEND:
|
||||
return 'You paid'
|
||||
case TransactionType.TOPUP:
|
||||
return 'You funded your wallet'
|
||||
case TransactionType.TRANSFER:
|
||||
return 'You transfered'
|
||||
default:
|
||||
return 'Uknown transaction'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getSubText = function(tx: Transaction) {
|
||||
|
||||
const distance = formatDistance(tx.createdAt as Date, new Date(), {addSuffix: true})
|
||||
|
||||
switch(tx.status) {
|
||||
case TransactionStatus.COMPLETED:
|
||||
return distance + ` · Completed`
|
||||
case TransactionStatus.DRAFT:
|
||||
return distance + ` · Draft`
|
||||
case TransactionStatus.ERROR:
|
||||
return distance + ` · Error`
|
||||
case TransactionStatus.PENDING:
|
||||
return distance + ` · Pending`
|
||||
case TransactionStatus.PREPARED:
|
||||
return distance + ` · Prepared`
|
||||
case TransactionStatus.EXPIRED:
|
||||
return distance + ` · Expired`
|
||||
default:
|
||||
return distance
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getLeftIcon = function(tx: Transaction) {
|
||||
if([TransactionStatus.ERROR, TransactionStatus.EXPIRED, TransactionStatus.BLOCKED].includes(tx.status)) {
|
||||
return (<Icon containerStyle={$txIconContainer} icon="faBan" size={spacing.medium} color={txErrorColor}/>)
|
||||
}
|
||||
|
||||
if([TransactionType.TOPUP].includes(tx.type) && tx.status === TransactionStatus.PENDING) {
|
||||
return (<Icon containerStyle={$txIconContainer} icon="faArrowTurnDown" size={spacing.medium} color={txPendingColor}/>)
|
||||
}
|
||||
|
||||
if([TransactionType.RECEIVE, TransactionType.TOPUP].includes(tx.type)) {
|
||||
return (<Icon containerStyle={$txIconContainer} icon="faArrowTurnDown" size={spacing.medium} color={txReceiveColor}/>)
|
||||
}
|
||||
|
||||
return (<Icon containerStyle={$txIconContainer} icon="faArrowTurnUp" size={spacing.medium} color={txSendColor}/>)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={tx.id}
|
||||
text={getText(tx)}
|
||||
textStyle={$mintText}
|
||||
subText={getSubText(tx)}
|
||||
LeftComponent={getLeftIcon(tx)}
|
||||
RightComponent={
|
||||
<View style={$txContainer}>
|
||||
{([TransactionType.RECEIVE].includes(tx.type)) && (
|
||||
<>
|
||||
{[TransactionStatus.COMPLETED].includes(tx.status) && (
|
||||
<Text style={[$txAmount, {color: txReceiveColor}]}>{tx.amount.toLocaleString()}</Text>
|
||||
)}
|
||||
{[TransactionStatus.ERROR, TransactionStatus.BLOCKED].includes(tx.status) && (
|
||||
<Text style={[$txAmount, {color: txErrorColor}]}>{tx.amount.toLocaleString()}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{([TransactionType.TOPUP].includes(tx.type)) && (
|
||||
<>
|
||||
{[TransactionStatus.PENDING, TransactionStatus.EXPIRED].includes(tx.status) && (
|
||||
<Text style={[$txAmount, {color: txPendingColor}]}>{tx.amount.toLocaleString()}</Text>
|
||||
)}
|
||||
{tx.status === TransactionStatus.COMPLETED && (
|
||||
<Text style={[$txAmount, {color: txReceiveColor}]}>{tx.amount.toLocaleString()}</Text>
|
||||
)}
|
||||
{tx.status === TransactionStatus.ERROR && (
|
||||
<Text style={[$txAmount, {color: txErrorColor}]}>{tx.amount.toLocaleString()}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{([TransactionType.SEND, TransactionType.TRANSFER].includes(tx.type)) && (
|
||||
<Text style={[$txAmount, {color: (tx.status === TransactionStatus.ERROR) ? txErrorColor : txSendColor}]}>-{tx.amount.toLocaleString()}</Text>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
topSeparator={props.isFirst ? false : true}
|
||||
style={$item}
|
||||
onPress={() => props.gotoTranDetail(tx.id)}
|
||||
/>
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
flex: 1,
|
||||
padding: spacing.extraSmall,
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $cardHeading: TextStyle = {
|
||||
fontFamily: typography.primary?.medium,
|
||||
fontSize: 20,
|
||||
}
|
||||
|
||||
|
||||
const $item: ViewStyle = {
|
||||
marginHorizontal: spacing.micro,
|
||||
}
|
||||
|
||||
const $mintText: TextStyle = {
|
||||
overflow: 'hidden',
|
||||
fontSize: 14,
|
||||
}
|
||||
|
||||
const $balanceContainer: ViewStyle = {
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.extraSmall
|
||||
}
|
||||
|
||||
const $balance: TextStyle = {
|
||||
fontSize: 20,
|
||||
fontFamily: typography.primary?.medium
|
||||
}
|
||||
|
||||
const $txContainer: ViewStyle = {
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.extraSmall
|
||||
}
|
||||
|
||||
const $txAmount: TextStyle = {
|
||||
fontFamily: typography.primary?.medium
|
||||
}
|
||||
|
||||
const $txIconContainer: ViewStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
alignSelf: "center",
|
||||
marginRight: spacing.medium,
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useEffect, useState, useCallback, useRef} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {
|
||||
UIManager,
|
||||
Platform,
|
||||
Alert,
|
||||
TextInput,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
LayoutAnimation,
|
||||
ScrollView,
|
||||
Share,
|
||||
} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import {spacing, typography, useThemeColor, colors} from '../theme'
|
||||
import {useSafeAreaInsetsStyle} from '../utils/useSafeAreaInsetsStyle'
|
||||
import {WalletStackScreenProps} from '../navigation'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Card,
|
||||
Screen,
|
||||
Loading,
|
||||
InfoModal,
|
||||
ErrorModal,
|
||||
ListItem,
|
||||
BottomModal,
|
||||
Text,
|
||||
IconTypes,
|
||||
} from '../components'
|
||||
import {Mint} from '../models/Mint'
|
||||
import {Transaction, TransactionStatus} from '../models/Transaction'
|
||||
import {useStores} from '../models'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {MintClient, Wallet} from '../services'
|
||||
import {log} from '../utils/logger'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {translate} from '../i18n'
|
||||
import {
|
||||
decodeInvoice,
|
||||
LightningInvoice,
|
||||
getInvoiceData,
|
||||
} from '../services/cashuHelpers'
|
||||
import {MintBalance} from '../models/Mint'
|
||||
import {isNumber} from 'lodash'
|
||||
import {MintListItem} from './Mints/MintListItem'
|
||||
import {ResultModalInfo} from './Wallet/ResultModalInfo'
|
||||
|
||||
if (
|
||||
Platform.OS === 'android' &&
|
||||
UIManager.setLayoutAnimationEnabledExperimental
|
||||
) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true)
|
||||
}
|
||||
|
||||
export const TransferScreen: FC<WalletStackScreenProps<'Transfer'>> = observer(
|
||||
function TransferScreen({route, navigation}) {
|
||||
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
onLeftPress: () => navigation.goBack(),
|
||||
})
|
||||
|
||||
const {proofsStore, mintsStore, transactionsStore} = useStores()
|
||||
|
||||
|
||||
const [encodedInvoice, setEncodedInvoice] = useState<string>('')
|
||||
const [invoice, setInvoice] = useState<LightningInvoice | undefined>()
|
||||
const [amountToTransfer, setAmountToTransfer] = useState<number>(0)
|
||||
const [estimatedFee, setEstimatedFee] = useState<number>(0)
|
||||
const [finalFee, setFinalFee] = useState<number>(0)
|
||||
const [memo, setMemo] = useState('')
|
||||
const [availableMintBalances, setAvailableMintBalances] = useState<
|
||||
MintBalance[]
|
||||
>([])
|
||||
const [mintBalanceToTransferFrom, setMintBalanceToTransferFrom] = useState<
|
||||
MintBalance | undefined
|
||||
>()
|
||||
const [transactionStatus, setTransactionStatus] = useState<
|
||||
TransactionStatus | undefined
|
||||
>()
|
||||
const [info, setInfo] = useState('')
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isPasteInvoiceModalVisible, setIsPasteInvoiceModalVisible] =
|
||||
useState(false)
|
||||
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
|
||||
const [resultModalInfo, setResultModalInfo] = useState<
|
||||
{status: TransactionStatus; message: string} | undefined
|
||||
>()
|
||||
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!route.params || !route.params?.scannedEncodedInvoice) {return}
|
||||
|
||||
const encoded = route.params?.scannedEncodedInvoice
|
||||
onEncodedInvoice(encoded)
|
||||
}, [route.params?.scannedEncodedInvoice]),
|
||||
)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const getEstimatedFee = async function () {
|
||||
try {
|
||||
if (!mintBalanceToTransferFrom) return
|
||||
const fee = await MintClient.getLightningFee(
|
||||
mintBalanceToTransferFrom.mint,
|
||||
encodedInvoice,
|
||||
)
|
||||
|
||||
if (amountToTransfer + fee > mintBalanceToTransferFrom.balance) {
|
||||
setInfo(
|
||||
'There is not enough funds to cover expected lightning network fee. Try to select another mint with higher balance.',
|
||||
)
|
||||
}
|
||||
|
||||
setEstimatedFee(fee)
|
||||
} catch (e: any) {
|
||||
resetState()
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
getEstimatedFee()
|
||||
}, [mintBalanceToTransferFrom])
|
||||
|
||||
const resetState = function () {
|
||||
setEncodedInvoice('')
|
||||
setInvoice(undefined)
|
||||
setAmountToTransfer(0)
|
||||
setEstimatedFee(0)
|
||||
setMemo('')
|
||||
setAvailableMintBalances([])
|
||||
setMintBalanceToTransferFrom(undefined)
|
||||
setTransactionStatus(undefined)
|
||||
setInfo('')
|
||||
setError(undefined)
|
||||
setIsLoading(false)
|
||||
setIsPasteInvoiceModalVisible(false)
|
||||
setIsResultModalVisible(false)
|
||||
setResultModalInfo(undefined)
|
||||
}
|
||||
|
||||
const gotoScan = function () {
|
||||
navigation.navigate('Scan')
|
||||
}
|
||||
|
||||
const togglePasteInvoiceModal = () =>
|
||||
setIsPasteInvoiceModalVisible(previousState => !previousState)
|
||||
const toggleResultModal = () =>
|
||||
setIsResultModalVisible(previousState => !previousState)
|
||||
|
||||
|
||||
const onMintBalanceSelect = function (balance: MintBalance) {
|
||||
setMintBalanceToTransferFrom(balance) // this triggers effect to get estimated fees
|
||||
}
|
||||
|
||||
const onPasteInvoice = async function () {
|
||||
const encoded = await Clipboard.getString()
|
||||
if (!encoded) {
|
||||
setInfo('Copy received invoice first, then paste')
|
||||
return
|
||||
}
|
||||
togglePasteInvoiceModal()
|
||||
return onEncodedInvoice(encoded)
|
||||
}
|
||||
|
||||
const onEncodedInvoice = async function (encoded: string) {
|
||||
try {
|
||||
setEncodedInvoice(encoded)
|
||||
|
||||
const invoice = decodeInvoice(encoded)
|
||||
const {amount, expiry, description} = getInvoiceData(invoice)
|
||||
|
||||
log.trace('Decoded invoice', invoice)
|
||||
log.trace('Invoice data', {amount, expiry, description})
|
||||
|
||||
if (!amount || amount === 0) {
|
||||
setInfo('Invoice amount should be positive number')
|
||||
return
|
||||
}
|
||||
|
||||
setInvoice(invoice)
|
||||
setAmountToTransfer(amount)
|
||||
// TODO expiry check or countdown
|
||||
if (description) {setMemo(description)}
|
||||
|
||||
const availableBalances =
|
||||
proofsStore.getMintBalancesWithEnoughBalance(amount)
|
||||
|
||||
if (availableBalances.length === 0) {
|
||||
setInfo('There is not enough funds to send this amount')
|
||||
return
|
||||
}
|
||||
|
||||
log.trace('availableBalances', availableBalances.length)
|
||||
|
||||
setAvailableMintBalances(availableBalances)
|
||||
setMintBalanceToTransferFrom(availableBalances[0])
|
||||
} catch (e: any) {
|
||||
resetState()
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const transfer = async function () {
|
||||
setIsLoading(true)
|
||||
|
||||
const {transaction, message, error, finalFee} = await Wallet.transfer(
|
||||
mintBalanceToTransferFrom as MintBalance,
|
||||
amountToTransfer,
|
||||
estimatedFee,
|
||||
memo,
|
||||
encodedInvoice,
|
||||
)
|
||||
|
||||
const {status} = transaction as Transaction
|
||||
setTransactionStatus(status)
|
||||
|
||||
if (error) {
|
||||
setResultModalInfo({
|
||||
status,
|
||||
message: error.message,
|
||||
})
|
||||
} else {
|
||||
setResultModalInfo({
|
||||
status,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
if (finalFee) {
|
||||
setFinalFee(finalFee)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
toggleResultModal()
|
||||
}
|
||||
|
||||
|
||||
const handleError = function(e: AppError): void {
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
const feeColor = colors.palette.primary200
|
||||
|
||||
return (
|
||||
<Screen preset="auto" contentContainerStyle={$screen}>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
{invoice && amountToTransfer > 0 ? (
|
||||
<View style={$amountContainer}>
|
||||
<Text
|
||||
preset="subheading"
|
||||
text="Amount to transfer (sats)"
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
<Text
|
||||
style={$amountToTransfer}
|
||||
text={amountToTransfer.toLocaleString()}
|
||||
/>
|
||||
{transactionStatus === TransactionStatus.COMPLETED ? (
|
||||
<Text
|
||||
style={{color: feeColor}}
|
||||
text={`+ final fee ${estimatedFee.toLocaleString()} sats`}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={{color: feeColor}}
|
||||
text={`+ estimated fee ${finalFee.toLocaleString()} sats`}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Text preset="heading" text="Transfer" style={{color: 'white'}} />
|
||||
)}
|
||||
</View>
|
||||
<View style={$contentContainer}>
|
||||
{!resultModalInfo && !mintBalanceToTransferFrom && (
|
||||
<Card
|
||||
style={$optionsCard}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
tx="transferScreen.pasteLightningInvoice"
|
||||
subTx="transferScreen.pasteLightningInvoiceDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faBolt"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.secondary300}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={true}
|
||||
onPress={togglePasteInvoiceModal}
|
||||
/>
|
||||
<ListItem
|
||||
tx="transferScreen.scanLightningInvoice"
|
||||
subTx="transferScreen.scanLightningInvoiceDescription"
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faQrcode"
|
||||
size={spacing.medium}
|
||||
color={colors.palette.success200}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
style={$item}
|
||||
bottomSeparator={false}
|
||||
onPress={gotoScan}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mintBalanceToTransferFrom &&
|
||||
availableMintBalances.length > 0 &&
|
||||
transactionStatus !== TransactionStatus.COMPLETED && (
|
||||
<MintBalanceSelector
|
||||
availableMintBalances={availableMintBalances}
|
||||
mintBalanceToSendFrom={mintBalanceToTransferFrom as MintBalance}
|
||||
onMintBalanceSelect={onMintBalanceSelect}
|
||||
onCancel={resetState}
|
||||
findByUrl={mintsStore.findByUrl}
|
||||
onMintBalanceConfirm={transfer}
|
||||
/>
|
||||
)}
|
||||
{transactionStatus === TransactionStatus.COMPLETED && (
|
||||
<>
|
||||
<Card
|
||||
style={$card}
|
||||
ContentComponent={
|
||||
<MintListItem
|
||||
mint={
|
||||
mintsStore.findByUrl(
|
||||
mintBalanceToTransferFrom?.mint as string,
|
||||
) as Mint
|
||||
}
|
||||
isSelectable={false}
|
||||
mintBalance={proofsStore
|
||||
.getBalances()
|
||||
.mintBalances.find(
|
||||
balance =>
|
||||
balance.mint === mintBalanceToTransferFrom?.mint,
|
||||
)}
|
||||
separator={undefined}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={resetState}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
<BottomModal
|
||||
isVisible={isPasteInvoiceModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.6}
|
||||
style={{marginHorizontal: spacing.extraSmall}}
|
||||
ContentComponent={
|
||||
<PasteInvoiceBlock
|
||||
togglePasteModal={togglePasteInvoiceModal}
|
||||
onPasteInvoice={onPasteInvoice}
|
||||
/>
|
||||
}
|
||||
onBackButtonPress={togglePasteInvoiceModal}
|
||||
onBackdropPress={togglePasteInvoiceModal}
|
||||
/>
|
||||
<BottomModal
|
||||
isVisible={isResultModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.6}
|
||||
ContentComponent={
|
||||
<>
|
||||
{resultModalInfo &&
|
||||
transactionStatus === TransactionStatus.COMPLETED && (
|
||||
<>
|
||||
<ResultModalInfo
|
||||
icon="faCheckCircle"
|
||||
iconColor={colors.palette.success200}
|
||||
title="Transfer completed"
|
||||
message={resultModalInfo?.message}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={resetState}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{resultModalInfo && transactionStatus === TransactionStatus.REVERTED && (
|
||||
<>
|
||||
<ResultModalInfo
|
||||
icon="faRotate"
|
||||
iconColor={colors.palette.accent300}
|
||||
title="Transfer reverted"
|
||||
message={resultModalInfo?.message}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={toggleResultModal}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{resultModalInfo &&
|
||||
transactionStatus === TransactionStatus.ERROR && (
|
||||
<>
|
||||
<ResultModalInfo
|
||||
icon="faTriangleExclamation"
|
||||
iconColor={colors.palette.angry500}
|
||||
title="Transfer failed"
|
||||
message={resultModalInfo?.message}
|
||||
/>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.close'}
|
||||
onPress={toggleResultModal}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
}
|
||||
onBackButtonPress={toggleResultModal}
|
||||
onBackdropPress={toggleResultModal}
|
||||
/>
|
||||
{error && <ErrorModal error={error} />}
|
||||
{info && <InfoModal message={info} />}
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const PasteInvoiceBlock = function (props: {
|
||||
togglePasteModal: any
|
||||
onPasteInvoice: any
|
||||
}) {
|
||||
return (
|
||||
<View style={$bottomModal}>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
tx={'common.paste'}
|
||||
onPress={() => props.onPasteInvoice()}
|
||||
style={{marginRight: spacing.medium}}
|
||||
/>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.cancel'}
|
||||
onPress={props.togglePasteModal}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const MintBalanceSelector = observer(function (props: {
|
||||
availableMintBalances: MintBalance[]
|
||||
mintBalanceToSendFrom: MintBalance
|
||||
onMintBalanceSelect: any
|
||||
onCancel: any
|
||||
findByUrl: any
|
||||
onMintBalanceConfirm: any
|
||||
}) {
|
||||
|
||||
const onMintSelect = function(balance: MintBalance) {
|
||||
log.trace('onMintBalanceSelect', balance.mint)
|
||||
return props.onMintBalanceSelect(balance)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
style={$card}
|
||||
heading={'Pay from'}
|
||||
headingStyle={{textAlign: 'center', padding: spacing.small}}
|
||||
ContentComponent={
|
||||
<>
|
||||
{props.availableMintBalances.map(
|
||||
(balance: MintBalance, index: number) => (
|
||||
<MintListItem
|
||||
key={balance.mint}
|
||||
mint={props.findByUrl(balance.mint)}
|
||||
mintBalance={balance}
|
||||
onMintSelect={() => onMintSelect(balance)}
|
||||
isSelectable={true}
|
||||
isSelected={props.mintBalanceToSendFrom.mint === balance.mint}
|
||||
separator={'top'}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<View style={[$buttonContainer, {marginTop: spacing.large}]}>
|
||||
<Button
|
||||
text="Transfer now"
|
||||
onPress={props.onMintBalanceConfirm}
|
||||
style={{marginRight: spacing.medium}}
|
||||
// LeftAccessory={() => <Icon icon="faCoins" color="white" size={spacing.medium} containerStyle={{marginRight: spacing.small}}/>}
|
||||
/>
|
||||
<Button
|
||||
preset="secondary"
|
||||
tx={'common.cancel'}
|
||||
onPress={props.onCancel}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
paddingTop: 0,
|
||||
height: spacing.screenHeight * 0.18,
|
||||
}
|
||||
|
||||
const $amountContainer: ViewStyle = {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
|
||||
const $amountToTransfer: TextStyle = {
|
||||
flex: 1,
|
||||
paddingTop: spacing.extraLarge + 10,
|
||||
fontSize: 52,
|
||||
fontWeight: '400',
|
||||
textAlignVertical: 'center',
|
||||
color: 'white',
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
padding: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $optionsCard: ViewStyle = {
|
||||
marginTop: -spacing.extraLarge * 2,
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingLeft: 0,
|
||||
}
|
||||
|
||||
const $bottomModal: ViewStyle = {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.large,
|
||||
paddingHorizontal: spacing.small,
|
||||
}
|
||||
|
||||
const $buttonContainer: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'center',
|
||||
}
|
||||
|
||||
const $receiveMsg: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
borderRadius: spacing.large,
|
||||
justifyContent: 'flex-start',
|
||||
padding: spacing.small,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react"
|
||||
import { View, ViewStyle } from "react-native"
|
||||
import { Button, Icon, IconTypes, ListItem, Screen, Text } from "../../components"
|
||||
import { colors, spacing, typography, useThemeColor } from "../../theme"
|
||||
|
||||
|
||||
export const ResultModalInfo = function(props: {
|
||||
icon: IconTypes,
|
||||
iconColor: string,
|
||||
title: string,
|
||||
message: string
|
||||
}
|
||||
) {
|
||||
|
||||
const textColor = useThemeColor('textDim')
|
||||
|
||||
return (
|
||||
<View style={$bottomModal}>
|
||||
<Icon icon={props.icon} size={80} color={props.iconColor} />
|
||||
<Text style={{marginTop: spacing.small}} text={props.title} />
|
||||
<Text
|
||||
style={{color: textColor, textAlign: 'center', marginTop: spacing.small}}
|
||||
text={props.message}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $bottomModal: ViewStyle = {
|
||||
// flex:1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.large,
|
||||
paddingHorizontal: spacing.small,
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useState, useEffect, useRef, useMemo} from 'react'
|
||||
import {
|
||||
ImageStyle,
|
||||
TextStyle,
|
||||
ViewStyle,
|
||||
View,
|
||||
ScrollView,
|
||||
Alert,
|
||||
Pressable,
|
||||
Text as RNText,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
useAnimatedScrollHandler,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {useThemeColor, spacing, colors, typography} from '../theme'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Screen,
|
||||
Text,
|
||||
Card,
|
||||
ListItem,
|
||||
InfoModal,
|
||||
Loading,
|
||||
BottomModal,
|
||||
ErrorModal,
|
||||
} from '../components'
|
||||
import {useStores} from '../models'
|
||||
import {WalletStackScreenProps} from '../navigation'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {Mint} from '../models/Mint'
|
||||
import {MintsByHostname} from '../models/MintsStore'
|
||||
import {log} from '../utils/logger'
|
||||
import {Transaction} from '../models/Transaction'
|
||||
import {TransactionListItem} from './Transactions/TransactionListItem'
|
||||
import {Database, MintClient, MintKeys, MintKeySets, Wallet} from '../services'
|
||||
import {translate} from '../i18n'
|
||||
import AppError from '../utils/AppError'
|
||||
|
||||
interface WalletScreenProps extends WalletStackScreenProps<'Wallet'> {}
|
||||
|
||||
type MintBalance = {
|
||||
mint: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
export const WalletScreen: FC<WalletScreenProps> = observer(
|
||||
function WalletScreen(_props) {
|
||||
const {navigation} = _props
|
||||
const {mintsStore, proofsStore, transactionsStore} = useStores()
|
||||
|
||||
useHeader({
|
||||
rightIcon: 'faBolt',
|
||||
rightIconColor: colors.palette.primary100,
|
||||
onRightPress: () => toggleLightningModal(),
|
||||
leftIcon: 'faListUl',
|
||||
leftIconColor: colors.palette.primary100,
|
||||
onLeftPress: () => gotoTranHistory(),
|
||||
})
|
||||
|
||||
const scrollY = useSharedValue(0)
|
||||
|
||||
const handleScroll = useAnimatedScrollHandler(event => {
|
||||
scrollY.value = event.contentOffset.y
|
||||
})
|
||||
|
||||
const stylez = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: scrollY.value,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const [info, setInfo] = useState<string>('')
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLightningModalVisible, setIsLightningModalVisible] =
|
||||
useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check with mints if some of pending SEND and TOPUP transactions
|
||||
// were completed and update balances accordingly
|
||||
Wallet.checkPendingSpent()
|
||||
Wallet.checkPendingTopups()
|
||||
}, [])
|
||||
|
||||
const toggleLightningModal = () =>
|
||||
setIsLightningModalVisible(previousState => !previousState)
|
||||
|
||||
const addMint = async function () {
|
||||
const mintUrl = 'https://mint.minibits.cash/Bitcoin'
|
||||
|
||||
if (mintsStore.alreadyExists(mintUrl)) {
|
||||
const msg = translate('mintsScreen.mintExists')
|
||||
log.info(msg)
|
||||
setInfo(msg)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const mintKeys: {
|
||||
keys: MintKeys
|
||||
keysets: MintKeySets
|
||||
} = await MintClient.getMintKeys(mintUrl)
|
||||
|
||||
const newMint: Mint = {
|
||||
mintUrl,
|
||||
keys: mintKeys.keys,
|
||||
keysets: mintKeys.keysets.keysets,
|
||||
}
|
||||
|
||||
mintsStore.addMint(newMint)
|
||||
|
||||
// log.trace('Snapshot after add mint', getSnapshot(mintsStore))
|
||||
|
||||
// setInfo(translate('mintsScreen.mintAdded'))
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const gotoReceive = function () {
|
||||
navigation.navigate('Receive')
|
||||
}
|
||||
|
||||
const gotoScan = function () {
|
||||
navigation.navigate('Scan')
|
||||
}
|
||||
|
||||
const gotoSend = function () {
|
||||
navigation.navigate('Send')
|
||||
}
|
||||
|
||||
const gotoTranHistory = function () {
|
||||
navigation.navigate('TranHistory')
|
||||
}
|
||||
|
||||
const gotoTranDetail = function (id: number) {
|
||||
navigation.navigate('TranDetail', {id} as any)
|
||||
}
|
||||
|
||||
const gotoWithdraw = function () {
|
||||
toggleLightningModal() // close
|
||||
navigation.navigate('Transfer')
|
||||
}
|
||||
|
||||
const gotoTopup = function () {
|
||||
toggleLightningModal() // close
|
||||
navigation.navigate('Topup')
|
||||
}
|
||||
|
||||
const handleError = function (e: AppError) {
|
||||
setIsLoading(false)
|
||||
setError(e)
|
||||
}
|
||||
|
||||
const balances = proofsStore.getBalances()
|
||||
const screenBg = useThemeColor('background')
|
||||
|
||||
return (
|
||||
<Screen preset="auto" contentContainerStyle={$screen}>
|
||||
<Animated.ScrollView
|
||||
style={[$screen, {backgroundColor: screenBg}]}
|
||||
scrollEventThrottle={16}
|
||||
onScroll={handleScroll}>
|
||||
<TotalBalanceBlock
|
||||
totalBalance={balances.totalBalance}
|
||||
pendingBalance={balances.totalPendingBalance}
|
||||
// gotoTranHistory={gotoTranHistory}
|
||||
/>
|
||||
<View style={$contentContainer}>
|
||||
{mintsStore.mintCount === 0 && <PromoBlock addMint={addMint} />}
|
||||
{mintsStore.groupedByHostname.map(
|
||||
(mintsByHostname: MintsByHostname) => (
|
||||
<MintsByHostnameListItem
|
||||
key={mintsByHostname.hostname}
|
||||
mintsByHostname={mintsByHostname}
|
||||
mintBalances={balances.mintBalances}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{transactionsStore.count > 0 && (
|
||||
<Card
|
||||
ContentComponent={
|
||||
<>
|
||||
{transactionsStore.recent.map(
|
||||
(tx: Transaction, index: number) => (
|
||||
<TransactionListItem
|
||||
key={tx.id}
|
||||
tx={tx}
|
||||
isFirst={index === 0}
|
||||
gotoTranDetail={gotoTranDetail}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
}
|
||||
style={$card}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Loading />}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
<Animated.View style={[$bottomContainer, stylez]}>
|
||||
<View style={$buttonContainer}>
|
||||
<Button
|
||||
tx={'walletScreen.receive'}
|
||||
LeftAccessory={() => (
|
||||
<Icon
|
||||
icon="faArrowDown"
|
||||
color="white"
|
||||
size={spacing.medium}
|
||||
containerStyle={{marginRight: spacing.small}}
|
||||
/>
|
||||
)}
|
||||
onPress={gotoReceive}
|
||||
style={$buttonReceive}
|
||||
/>
|
||||
<Button
|
||||
RightAccessory={() => (
|
||||
<Icon
|
||||
icon="faExpand"
|
||||
color="white"
|
||||
size={spacing.medium}
|
||||
// containerStyle={{marginLeft: spacing.small}}
|
||||
/>
|
||||
)}
|
||||
onPress={gotoScan}
|
||||
style={$buttonScan}
|
||||
/>
|
||||
<Button
|
||||
tx={'walletScreen.send'}
|
||||
RightAccessory={() => (
|
||||
<Icon
|
||||
icon="faArrowUp"
|
||||
color="white"
|
||||
size={spacing.medium}
|
||||
containerStyle={{marginLeft: spacing.small}}
|
||||
/>
|
||||
)}
|
||||
onPress={gotoSend}
|
||||
style={$buttonSend}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
<BottomModal
|
||||
isVisible={isLightningModalVisible ? true : false}
|
||||
top={spacing.screenHeight * 0.65}
|
||||
ContentComponent={
|
||||
<LightningActionsBlock
|
||||
gotoWithdraw={gotoWithdraw}
|
||||
gotoTopup={gotoTopup}
|
||||
/>
|
||||
}
|
||||
onBackButtonPress={toggleLightningModal}
|
||||
onBackdropPress={toggleLightningModal}
|
||||
/>
|
||||
{info && <InfoModal message={info} />}
|
||||
{error && <ErrorModal error={error} />}
|
||||
</Screen>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const TotalBalanceBlock = observer(function (props: {
|
||||
totalBalance: number
|
||||
pendingBalance: number
|
||||
}) {
|
||||
const headerBg = useThemeColor('header')
|
||||
const balanceColor = 'white'
|
||||
const pendingBalanceColor = colors.palette.primary200
|
||||
|
||||
return (
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Text
|
||||
testID="total-balance"
|
||||
preset="heading"
|
||||
style={{color: balanceColor}}
|
||||
text={props.totalBalance.toLocaleString()}
|
||||
/>
|
||||
{props.pendingBalance > 0 && (
|
||||
<Text
|
||||
testID="pending-balance"
|
||||
preset="default"
|
||||
style={{color: pendingBalanceColor}}
|
||||
text={`Pending: ${props.pendingBalance.toLocaleString()}`}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const PromoBlock = function (props: {addMint: any}) {
|
||||
return (
|
||||
<Card
|
||||
HeadingComponent={
|
||||
<View style={$promoIconContainer}>
|
||||
<Icon icon="faBurst" size={50} color={colors.palette.accent400} />
|
||||
</View>
|
||||
}
|
||||
ContentComponent={
|
||||
<View style={{flexDirection: 'row'}}>
|
||||
<RNText style={$promoText}>
|
||||
Add{' '}
|
||||
<Text
|
||||
text="Minibits"
|
||||
style={{fontFamily: 'Gluten-Regular', fontSize: 18}}
|
||||
/>{' '}
|
||||
as your first mint to start!
|
||||
</RNText>
|
||||
</View>
|
||||
}
|
||||
style={$card}
|
||||
FooterComponent={
|
||||
<View style={{alignItems: 'center'}}>
|
||||
<Button
|
||||
preset="default"
|
||||
onPress={props.addMint}
|
||||
text="Add your first mint"
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const MintsByHostnameListItem = observer(function (props: {
|
||||
mintsByHostname: MintsByHostname
|
||||
mintBalances: MintBalance[]
|
||||
}) {
|
||||
const color = useThemeColor('textDim')
|
||||
const balanceColor = useThemeColor('amount')
|
||||
|
||||
return (
|
||||
<Card
|
||||
verticalAlignment="force-footer-bottom"
|
||||
HeadingComponent={
|
||||
<ListItem
|
||||
text={props.mintsByHostname.hostname}
|
||||
textStyle={$cardHeading}
|
||||
style={{marginHorizontal: spacing.micro}}
|
||||
/>
|
||||
}
|
||||
ContentComponent={
|
||||
<>
|
||||
{props.mintsByHostname.mints.map((mint: Mint) => (
|
||||
<ListItem
|
||||
key={mint.mintUrl}
|
||||
text={mint.shortname}
|
||||
textStyle={[$mintText, {color}]}
|
||||
LeftComponent={
|
||||
<Icon
|
||||
icon="faCoins"
|
||||
size={spacing.medium}
|
||||
color={mint.color}
|
||||
inverse={true}
|
||||
/>
|
||||
}
|
||||
RightComponent={
|
||||
<View style={$balanceContainer}>
|
||||
<Text style={[$balance, {color: balanceColor}]}>
|
||||
{props.mintBalances.find(b => b.mint === mint.mintUrl)
|
||||
?.balance || 0}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
topSeparator={true}
|
||||
style={$item}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
contentStyle={{color}}
|
||||
// footer={'Some text'}
|
||||
style={$card}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const LightningActionsBlock = function (props: {
|
||||
gotoWithdraw: any
|
||||
gotoTopup: any
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
tx="walletScreen.topUpWallet"
|
||||
subTx="walletScreen.topUpWalletSubText"
|
||||
leftIcon="faArrowRightToBracket"
|
||||
leftIconTransform="rotate-90"
|
||||
onPress={props.gotoTopup}
|
||||
bottomSeparator={true}
|
||||
style={{paddingHorizontal: spacing.medium}}
|
||||
/>
|
||||
<ListItem
|
||||
tx="walletScreen.transferFromWallet"
|
||||
subTx="walletScreen.transferFromWalletSubText"
|
||||
leftIcon="faArrowUpFromBracket"
|
||||
onPress={props.gotoWithdraw}
|
||||
style={{paddingHorizontal: spacing.medium}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const $screen: ViewStyle = {
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.medium,
|
||||
height: spacing.screenHeight * 0.18,
|
||||
}
|
||||
|
||||
const $buttonContainer: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'center',
|
||||
marginTop: spacing.medium,
|
||||
}
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
marginTop: -spacing.extraLarge * 2,
|
||||
flex: 1,
|
||||
padding: spacing.extraSmall,
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const $card: ViewStyle = {
|
||||
marginBottom: spacing.small,
|
||||
paddingTop: 0,
|
||||
}
|
||||
|
||||
const $cardHeading: TextStyle = {
|
||||
fontFamily: typography.primary?.medium,
|
||||
fontSize: 20,
|
||||
}
|
||||
|
||||
const $promoIconContainer: ViewStyle = {
|
||||
marginTop: -spacing.large,
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const $promoText: TextStyle = {
|
||||
padding: spacing.small,
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
}
|
||||
|
||||
const $item: ViewStyle = {
|
||||
marginHorizontal: spacing.micro,
|
||||
}
|
||||
|
||||
const $mintText: TextStyle = {
|
||||
overflow: 'hidden',
|
||||
fontSize: 14,
|
||||
}
|
||||
|
||||
const $balanceContainer: ViewStyle = {
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
marginRight: spacing.extraSmall,
|
||||
}
|
||||
|
||||
const $balance: TextStyle = {
|
||||
fontSize: 20,
|
||||
fontFamily: typography.primary?.medium,
|
||||
}
|
||||
|
||||
const $bottomContainer: ViewStyle = {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: spacing.medium,
|
||||
alignSelf: 'stretch',
|
||||
// opacity: 0,
|
||||
}
|
||||
|
||||
const $buttonReceive: ViewStyle = {
|
||||
borderTopLeftRadius: 30,
|
||||
borderBottomLeftRadius: 30,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
minWidth: 130,
|
||||
borderRightWidth: 1,
|
||||
}
|
||||
|
||||
const $buttonScan: ViewStyle = {
|
||||
borderRadius: 0,
|
||||
minWidth: 60,
|
||||
}
|
||||
|
||||
const $buttonSend: ViewStyle = {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderTopRightRadius: 30,
|
||||
borderBottomRightRadius: 30,
|
||||
minWidth: 130,
|
||||
borderLeftWidth: 1,
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// import { observer } from "mobx-react-lite"
|
||||
import React, {FC} from 'react'
|
||||
import {
|
||||
ImageStyle,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
useColorScheme,
|
||||
FlatList,
|
||||
} from 'react-native'
|
||||
// import { isRTL } from "../i18n"
|
||||
import {useStores} from '../models'
|
||||
import {AppStackScreenProps} from '../navigation'
|
||||
import {useThemeColor, spacing, typography, colors} from '../theme'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {useSafeAreaInsetsStyle} from '../utils/useSafeAreaInsetsStyle'
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Screen,
|
||||
Text,
|
||||
TextField,
|
||||
TextFieldAccessoryProps,
|
||||
} from '../components'
|
||||
import {TxKeyPath} from '../i18n'
|
||||
|
||||
// const welcomeLogo = require("../../assets/images/logo.png")
|
||||
|
||||
export const WelcomeScreen: FC<AppStackScreenProps<'Welcome'>> =
|
||||
function WelcomeScreen(_props) {
|
||||
const {navigation} = _props
|
||||
|
||||
useHeader({
|
||||
backgroundColor: useThemeColor('background'),
|
||||
StatusBarProps: {barStyle: 'dark-content'},
|
||||
})
|
||||
|
||||
const {userSettingsStore} = useStores()
|
||||
|
||||
const gotoWallet = function () {
|
||||
userSettingsStore.setIsOnboarded(true)
|
||||
navigation.navigate('Tabs')
|
||||
}
|
||||
|
||||
const $bottomContainerInsets = useSafeAreaInsetsStyle(['bottom'])
|
||||
|
||||
const warnings = [
|
||||
{id: '1', tx: 'welcomeScreen.warning1'},
|
||||
{id: '2', tx: 'welcomeScreen.warning2'},
|
||||
{id: '3', tx: 'welcomeScreen.warning3'},
|
||||
{id: '4', tx: 'welcomeScreen.warning4'},
|
||||
]
|
||||
|
||||
const renderWarningItem = ({item}: {item: {id: string; tx: string}}) => (
|
||||
<View style={$listItem}>
|
||||
<View style={$itemIcon}>
|
||||
<Icon
|
||||
icon="faCheckCircle"
|
||||
size={spacing.large}
|
||||
color={colors.palette.primary400}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
tx={item.tx as TxKeyPath}
|
||||
style={{paddingHorizontal: spacing.small}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<Screen style={$container} preset="fixed">
|
||||
<View>
|
||||
<Text
|
||||
tx="welcomeScreen.heading"
|
||||
testID="welcome-heading"
|
||||
preset="heading"
|
||||
style={$welcomeHeading}
|
||||
/>
|
||||
<Text
|
||||
tx="welcomeScreen.intro"
|
||||
preset="default"
|
||||
style={$welcomeIntro}
|
||||
/>
|
||||
<View style={{maxHeight: spacing.screenHeight * 0.3}}>
|
||||
<FlatList
|
||||
data={warnings}
|
||||
renderItem={renderWarningItem}
|
||||
keyExtractor={item => item.id}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[$bottomContainer, $bottomContainerInsets]}>
|
||||
<Button
|
||||
testID="login-button"
|
||||
tx="welcomeScreen.go"
|
||||
preset="default"
|
||||
onPress={gotoWallet}
|
||||
/>
|
||||
</View>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
const $container: ViewStyle = {
|
||||
alignItems: 'center',
|
||||
padding: spacing.medium,
|
||||
}
|
||||
|
||||
const $listItem: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
marginBottom: spacing.small,
|
||||
}
|
||||
|
||||
const $itemIcon: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
marginBottom: spacing.small,
|
||||
}
|
||||
|
||||
const $bottomContainer: ViewStyle = {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: spacing.large,
|
||||
alignSelf: 'stretch',
|
||||
}
|
||||
|
||||
const $welcomeHeading: TextStyle = {
|
||||
marginBottom: spacing.medium,
|
||||
}
|
||||
|
||||
const $welcomeIntro: TextStyle = {
|
||||
marginBottom: spacing.large,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export * from './WelcomeScreen'
|
||||
|
||||
// TabNavigator
|
||||
export * from './WalletScreen'
|
||||
export * from './ReceiveScreen'
|
||||
export * from './SendScreen'
|
||||
export * from './ScanScreen'
|
||||
export * from './TranDetailScreen'
|
||||
export * from './TranHistoryScreen'
|
||||
export * from './TransferScreen'
|
||||
export * from './TopupScreen'
|
||||
|
||||
export * from './ContactsScreen'
|
||||
|
||||
// SettingsNavigator
|
||||
export * from './SettingsScreen'
|
||||
export * from './MintsScreen'
|
||||
export * from './SecurityScreen'
|
||||
export * from './BackupScreen'
|
||||
export * from './LocalRecoveryScreen'
|
||||
export * from './DeveloperScreen'
|
||||
export * from './ErrorScreen/ErrorBoundary'
|
||||
@@ -0,0 +1,88 @@
|
||||
/* import _AsyncStorage from "@react-native-async-storage/async-storage" */
|
||||
|
||||
/**
|
||||
* Loads a string from storage.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
*/
|
||||
/* const loadString = async function (key: string): Promise<string | null> {
|
||||
try {
|
||||
return await _AsyncStorage.getItem(key)
|
||||
} catch {
|
||||
// not sure why this would fail... even reading the RN docs I'm unclear
|
||||
return null
|
||||
}
|
||||
} */
|
||||
|
||||
/**
|
||||
* Saves a string to storage.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
* @param value The value to store.
|
||||
*/
|
||||
/* const saveString = async function (key: string, value: string): Promise<boolean> {
|
||||
try {
|
||||
await _AsyncStorage.setItem(key, value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} */
|
||||
|
||||
/**
|
||||
* Loads something from storage and runs it thru JSON.parse.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
*/
|
||||
/* const load = async function (key: string): Promise<any | null> {
|
||||
try {
|
||||
const almostThere = await _AsyncStorage.getItem(key)
|
||||
return JSON.parse(almostThere as string)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
} */
|
||||
|
||||
/**
|
||||
* Saves an object to storage.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
* @param value The value to store.
|
||||
*/
|
||||
/* const save = async function (key: string, value: any): Promise<boolean> {
|
||||
try {
|
||||
await _AsyncStorage.setItem(key, JSON.stringify(value))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} */
|
||||
|
||||
/**
|
||||
* Removes something from storage.
|
||||
*
|
||||
* @param key The key to kill.
|
||||
*/
|
||||
/* const remove = async function (key: string): Promise<void> {
|
||||
try {
|
||||
await _AsyncStorage.removeItem(key)
|
||||
} catch {}
|
||||
} */
|
||||
|
||||
/**
|
||||
* Burn it all to the ground.
|
||||
*/
|
||||
/* const cleanAll = async function (): Promise<void> {
|
||||
try {
|
||||
await _AsyncStorage.clear()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const AsyncStorage = {
|
||||
loadString,
|
||||
saveString,
|
||||
load,
|
||||
save,
|
||||
remove,
|
||||
cleanAll
|
||||
} */
|
||||
@@ -0,0 +1,311 @@
|
||||
import {Mint} from '../models/Mint'
|
||||
import {
|
||||
getDecodedLnInvoice,
|
||||
getDecodedToken,
|
||||
} from '@cashu/cashu-ts'
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {Token} from '../models/Token'
|
||||
import {TokenEntry} from '../models/TokenEntry'
|
||||
import {Proof} from '../models/Proof'
|
||||
|
||||
export type LightningInvoice = {
|
||||
paymentRequest: string
|
||||
sections: any[]
|
||||
readonly expiry: any
|
||||
readonly route_hints: any[]
|
||||
}
|
||||
|
||||
export type DecodedLightningInvoice = {
|
||||
paymentRequest: string
|
||||
sections: any[]
|
||||
readonly expiry: any
|
||||
readonly route_hints: any[]
|
||||
}
|
||||
|
||||
export const decodeToken = function (encoded: string): Token {
|
||||
try {
|
||||
const decoded = getDecodedToken(encoded)
|
||||
return decoded as Token
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`Provided coins are invalid: ${encoded}`,
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const decodeInvoice = function (encoded: string): LightningInvoice {
|
||||
try {
|
||||
const decoded = getDecodedLnInvoice(encoded)
|
||||
return decoded as LightningInvoice
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`Provided invoice is invalid: ${encoded}`,
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const getInvoiceData = function (decoded: DecodedLightningInvoice) {
|
||||
const result: {amount?: number; description?: string; expiry?: number} = {}
|
||||
|
||||
for (const item of decoded.sections) {
|
||||
switch (item.name) {
|
||||
case 'amount':
|
||||
result.amount = parseInt(item.value) / 1000 //sats
|
||||
break
|
||||
case 'description':
|
||||
result.description = (item.value as string) || ''
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result.expiry = decoded.expiry
|
||||
return result
|
||||
}
|
||||
|
||||
export const getTokenAmounts = function (token: Token) {
|
||||
const mintAmounts: {[k: string]: number} = {}
|
||||
let totalAmount = 0
|
||||
|
||||
try {
|
||||
for (const tokenEntry of token.token) {
|
||||
const amount = getTokenEntryAmount(tokenEntry)
|
||||
totalAmount += amount
|
||||
|
||||
const mint = tokenEntry.mint
|
||||
|
||||
if (mintAmounts[mint]) {
|
||||
mintAmounts[mint] += amount
|
||||
} else {
|
||||
mintAmounts[mint] = amount
|
||||
}
|
||||
}
|
||||
|
||||
const mintAmountsArray = Object.entries(mintAmounts).map(
|
||||
([mintUrl, amount]) => ({mintUrl, amount}),
|
||||
)
|
||||
return {totalAmount, mintAmounts: mintAmountsArray}
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Could not calculate total amount',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const getTokenEntryAmount = function (tokenEntry: TokenEntry) {
|
||||
try {
|
||||
return getProofsAmount(tokenEntry.proofs)
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Could not calculate total TokenEntry amount',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const getProofsAmount = function (proofs: Array<Proof>): number {
|
||||
let totalAmount = 0
|
||||
|
||||
for (const proof of proofs) {
|
||||
const amount = proof.amount
|
||||
totalAmount += amount
|
||||
}
|
||||
|
||||
return totalAmount
|
||||
}
|
||||
|
||||
export const getMintsFromToken = function (token: Token): string[] {
|
||||
return token.token.map(item => item.mint)
|
||||
}
|
||||
|
||||
export const updateMintProofs = function (
|
||||
token: Token,
|
||||
mintUrl: string,
|
||||
updatedProofs: Proof[],
|
||||
): Token {
|
||||
// Find the index of the mint to update
|
||||
const mintIndex = token.token.findIndex(mint => mint.mint === mintUrl)
|
||||
|
||||
if (mintIndex === -1) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`Mint ${mintUrl} not found in token`,
|
||||
)
|
||||
}
|
||||
|
||||
// Clone the token instance
|
||||
const updatedToken: Token = cloneDeep(token)
|
||||
|
||||
// Update the proofs for the specified mint
|
||||
updatedToken.token[mintIndex].proofs = updatedProofs
|
||||
|
||||
return updatedToken
|
||||
}
|
||||
|
||||
export const getProofsFromTokenEntries = (tokenEntries: TokenEntry[]) => {
|
||||
const proofs: Proof[] = []
|
||||
|
||||
for (const entry of tokenEntries) {
|
||||
proofs.push(...entry.proofs)
|
||||
}
|
||||
|
||||
return proofs
|
||||
}
|
||||
|
||||
/**
|
||||
* returns a subset of tokens, so that not all tokens are sent to mint for smaller amounts.
|
||||
* @param amount
|
||||
* @param tokens
|
||||
* @returns
|
||||
*/
|
||||
export const getProofsToSend = function (amount: number, proofs: Array<Proof>) {
|
||||
let proofsAmount = 0
|
||||
const proofSubset = proofs.filter(proof => {
|
||||
if (proofsAmount < amount) {
|
||||
proofsAmount += proof.amount
|
||||
return true
|
||||
}
|
||||
})
|
||||
return proofSubset
|
||||
}
|
||||
|
||||
/**
|
||||
* removes a set of tokens from another set of tokens, and returns the remaining.
|
||||
* @param proofs
|
||||
* @param proofsToRemove
|
||||
* @returns
|
||||
*/
|
||||
export const getProofsSubset = function (
|
||||
proofs: Array<Proof>,
|
||||
proofsToRemove: Array<Proof>,
|
||||
): Array<Proof> {
|
||||
return proofs.filter(proof => !proofsToRemove.includes(proof))
|
||||
}
|
||||
|
||||
////////////////////////////
|
||||
// original nutstash methods
|
||||
////////////////////////////
|
||||
|
||||
/**
|
||||
* get a set of tokens from another set of tokens, and returns the remaining.
|
||||
* @param proofs
|
||||
* @param proofsToRemove
|
||||
* @returns
|
||||
*/
|
||||
export const getMintFromProof = function (
|
||||
proof: Proof,
|
||||
mints: Array<Mint>,
|
||||
): Mint | undefined {
|
||||
let mint: Mint | undefined
|
||||
mints.forEach(m => {
|
||||
if (m.keysets?.includes(proof.id)) {
|
||||
mint = m
|
||||
}
|
||||
})
|
||||
return mint
|
||||
}
|
||||
|
||||
export const validateMintKeys = function (keys: object): boolean {
|
||||
let isValid = true
|
||||
try {
|
||||
const allKeys = Object.keys(keys)
|
||||
|
||||
if (!allKeys) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (allKeys.length < 1) {
|
||||
return false
|
||||
}
|
||||
allKeys.forEach(k => {
|
||||
//try parse int?
|
||||
if (isNaN(k)) {
|
||||
isValid = false
|
||||
}
|
||||
if (!isPow2(k)) {
|
||||
isValid = false
|
||||
}
|
||||
})
|
||||
return isValid
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isPow2 = function (number: number) {
|
||||
return Math.log2(number) % 1 === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* returns a subset of all tokens that belong to the specified mint
|
||||
* @param mint
|
||||
* @param tokens
|
||||
* @returns
|
||||
*/
|
||||
export const getTokensForMint = function (mint: Mint, tokens: Array<Proof>) {
|
||||
const tokenSubset = tokens.filter(token => {
|
||||
if (mint?.keysets?.includes(token.id)) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return tokenSubset
|
||||
}
|
||||
|
||||
/**
|
||||
* removes a set of tokens from another set of tokens, and returns the remaining.
|
||||
* @param tokens
|
||||
* @param tokensToRemove
|
||||
* @returns
|
||||
*/
|
||||
export const getTokenSubset = function (
|
||||
tokens: Array<Proof>,
|
||||
tokensToRemove: Array<Proof>,
|
||||
) {
|
||||
return tokens.filter(token => !tokensToRemove.includes(token))
|
||||
}
|
||||
|
||||
export const getMintForToken = function (
|
||||
token: Proof,
|
||||
mints: Array<Mint>,
|
||||
): Mint | undefined {
|
||||
let mint: Mint | undefined
|
||||
mints.forEach(m => {
|
||||
if (m.keysets?.includes(token.id)) {
|
||||
mint = m
|
||||
}
|
||||
})
|
||||
return mint
|
||||
}
|
||||
|
||||
export const getAmountForTokenSet = function (tokens: Array<Proof>): number {
|
||||
return tokens.reduce((acc, t) => {
|
||||
return acc + t.amount
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export const getKeysetsOfTokens = function (tokens: Array<Proof>) {
|
||||
return removeDuplicatesFromArray(
|
||||
tokens.map(t => {
|
||||
return t.id
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const removeDuplicatesFromArray = function <Type>(array: Array<Type>) {
|
||||
return array.reduce((acc: Array<Type>, curr: Type) => {
|
||||
if (acc.includes(curr)) {
|
||||
return acc
|
||||
} else {
|
||||
return [...acc, curr]
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
CashuMint,
|
||||
CashuWallet,
|
||||
PayLnInvoiceResponse,
|
||||
type Proof as CashuProof,
|
||||
} from '@cashu/cashu-ts'
|
||||
import {validateMintKeys} from './cashuHelpers'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {log} from '../utils/logger'
|
||||
import {getProofsAmount} from '../services/cashuHelpers'
|
||||
import {Token} from '../models/Token'
|
||||
import {Proof} from '../models/Proof'
|
||||
|
||||
export type MintKeys = {[k: number]: string}
|
||||
export type MintKeySets = {keysets: Array<string>}
|
||||
|
||||
let _mints: {[mintUrl: string]: CashuMint} = {}
|
||||
let _wallets: {[mintUrl: string]: CashuWallet} = {}
|
||||
|
||||
const getMint = function (mintUrl: string) {
|
||||
if (_mints[mintUrl]) {
|
||||
return _mints[mintUrl]
|
||||
}
|
||||
|
||||
const mint = new CashuMint(mintUrl)
|
||||
_mints[mintUrl] = mint
|
||||
|
||||
return mint
|
||||
}
|
||||
|
||||
const getWallet = function (mintUrl: string) {
|
||||
if (_wallets[mintUrl]) {
|
||||
return _wallets[mintUrl]
|
||||
}
|
||||
|
||||
const mint = getMint(mintUrl)
|
||||
const wallet = new CashuWallet(mint)
|
||||
_wallets[mintUrl] = wallet
|
||||
|
||||
return wallet
|
||||
}
|
||||
|
||||
const getMintKeys = async function (mintURL: string) {
|
||||
const mint = getMint(mintURL)
|
||||
|
||||
let keys: MintKeys
|
||||
let keysets: MintKeySets
|
||||
|
||||
try {
|
||||
keysets = await mint.getKeySets()
|
||||
keys = await mint.getKeys()
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.CONNECTION_ERROR,
|
||||
'Could not connect to the selected mint.',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
|
||||
if (!validateMintKeys(keys)) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Invalid keys retrieved from the selected mint',
|
||||
mintURL,
|
||||
)
|
||||
}
|
||||
|
||||
const newMintKeys: {keys: MintKeys; keysets: MintKeySets} = {
|
||||
keys,
|
||||
keysets,
|
||||
}
|
||||
|
||||
return newMintKeys
|
||||
}
|
||||
|
||||
const receiveFromMint = async function (mintUrl: string, encodedToken: string) {
|
||||
try {
|
||||
const cashuWallet = getWallet(mintUrl)
|
||||
|
||||
// this method returns quite a mess, we normalize naming of returned parameters
|
||||
const {token, tokensWithErrors, newKeys} = await cashuWallet.receive(
|
||||
encodedToken,
|
||||
)
|
||||
|
||||
log.trace('[receiveFromMint] updatedToken', token)
|
||||
log.trace('[receiveFromMint] tokensWithErrors', tokensWithErrors)
|
||||
log.trace('[receiveFromMint] newKeys', newKeys)
|
||||
|
||||
// if (newKeys) { _setKeys(mintUrl, newKeys) }
|
||||
|
||||
return {
|
||||
updatedToken: token as Token,
|
||||
errorToken: tokensWithErrors as Token,
|
||||
newKeys,
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.MINT_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const sendFromMint = async function (
|
||||
mintUrl: string,
|
||||
amountToSend: number,
|
||||
proofsToSendFrom: Proof[],
|
||||
) {
|
||||
try {
|
||||
const cashuWallet = getWallet(mintUrl)
|
||||
|
||||
const {returnChange, send, newKeys} = await cashuWallet.send(
|
||||
amountToSend,
|
||||
proofsToSendFrom,
|
||||
)
|
||||
|
||||
log.trace('[MintClient.sendFromMint] returnedProofs', returnChange)
|
||||
log.trace('[MintClient.sendFromMint] sentProofs', send)
|
||||
log.trace('[MintClient.sendFromMint] newKeys', newKeys)
|
||||
|
||||
// do some basic validations that proof amounts from mints match
|
||||
const totalAmountToSendFrom = getProofsAmount(proofsToSendFrom)
|
||||
const returnedAmount = getProofsAmount(returnChange as Proof[])
|
||||
const proofsAmount = getProofsAmount(send as Proof[])
|
||||
|
||||
// if (newKeys) { _setKeys(mintUrl, newKeys) }
|
||||
|
||||
if (proofsAmount !== amountToSend) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`Amount to be sent does not equal requested original amount. Original is ${amountToSend}, mint returned ${proofsAmount}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (totalAmountToSendFrom !== returnedAmount + proofsAmount) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`Amount returned byt he mint as a change ${returnedAmount} is incorrect, it should be ${
|
||||
totalAmountToSendFrom - proofsAmount
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
// we normalize naming of returned parameters
|
||||
return {
|
||||
returnedProofs: returnChange as Proof[],
|
||||
proofsToSend: send as Proof[],
|
||||
newKeys,
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.MINT_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const getSpentProofsFromMint = async function (
|
||||
mintUrl: string,
|
||||
proofs: Proof[],
|
||||
) {
|
||||
try {
|
||||
const cashuWallet = getWallet(mintUrl)
|
||||
|
||||
const spentPendingProofs = await cashuWallet.checkProofsSpent(proofs)
|
||||
return spentPendingProofs
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.MINT_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const getLightningFee = async function (
|
||||
mintUrl: string,
|
||||
encodedInvoice: string,
|
||||
) {
|
||||
try {
|
||||
const cashuMint = getMint(mintUrl)
|
||||
const {fee} = await cashuMint.checkFees({pr: encodedInvoice})
|
||||
log.trace('Estimated fee', fee)
|
||||
return fee
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.MINT_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const payLightningInvoice = async function (
|
||||
mintUrl: string,
|
||||
encodedInvoice: string,
|
||||
proofsToPayFrom: CashuProof[],
|
||||
estimatedFee: number,
|
||||
) {
|
||||
try {
|
||||
log.trace('[payLightningInvoice] start')
|
||||
const cashuWallet = getWallet(mintUrl)
|
||||
|
||||
const {isPaid, change, preimage, newKeys}: PayLnInvoiceResponse =
|
||||
await cashuWallet.payLnInvoice(
|
||||
encodedInvoice,
|
||||
proofsToPayFrom,
|
||||
estimatedFee,
|
||||
)
|
||||
|
||||
// if (newKeys) { _setKeys(mintUrl, newKeys) }
|
||||
log.trace('[payLightningInvoice] result', {
|
||||
isPaid,
|
||||
change,
|
||||
preimage,
|
||||
newKeys,
|
||||
})
|
||||
// we normalize naming of returned parameters
|
||||
return {
|
||||
feeSavedProofs: change,
|
||||
isPaid,
|
||||
preimage,
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.MINT_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const requestLightningInvoice = async function (
|
||||
mintUrl: string,
|
||||
amount: number,
|
||||
) {
|
||||
try {
|
||||
const cashuWallet = getWallet(mintUrl)
|
||||
const {pr, hash} = await cashuWallet.requestMint(amount)
|
||||
|
||||
log.trace('Invoice', [pr, hash], 'requestLightningInvoice')
|
||||
|
||||
return {
|
||||
encodedInvoice: pr,
|
||||
paymentHash: hash,
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.MINT_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const requestProofs = async function (
|
||||
mintUrl: string,
|
||||
amount: number,
|
||||
paymentHash: string,
|
||||
) {
|
||||
try {
|
||||
const cashuWallet = getWallet(mintUrl)
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
const {proofs, newKeys} = await cashuWallet.requestTokens(
|
||||
amount,
|
||||
paymentHash,
|
||||
)
|
||||
/* eslint-enable */
|
||||
|
||||
// if (newKeys) { _setKeys(mintUrl, newKeys) }
|
||||
log.trace('[requestProofs]', proofs)
|
||||
|
||||
return proofs as CashuProof[]
|
||||
} catch (e: any) {
|
||||
// silent
|
||||
log.error(Err.MINT_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const MintClient = {
|
||||
getMintKeys,
|
||||
receiveFromMint,
|
||||
sendFromMint,
|
||||
getSpentProofsFromMint,
|
||||
getLightningFee,
|
||||
payLightningInvoice,
|
||||
requestLightningInvoice,
|
||||
requestProofs,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./cashuMintClient"
|
||||
export * from "./asyncStorage"
|
||||
export * from "./mmkvStorage"
|
||||
export * from "./sqlite"
|
||||
export * from "./keyChain"
|
||||
export * from "./walletService"
|
||||
@@ -0,0 +1,153 @@
|
||||
import * as _Keychain from 'react-native-keychain'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import QuickCrypto from 'react-native-quick-crypto'
|
||||
import {btoa, atob, fromByteArray} from 'react-native-quick-base64'
|
||||
import {log} from '../utils/logger'
|
||||
|
||||
export enum KeyChainServiceName {
|
||||
MMKV = 'MMKV',
|
||||
SQLITE = 'SQLITE',
|
||||
}
|
||||
|
||||
const generateKeyPair = async function () {
|
||||
try {
|
||||
const keyPair = QuickCrypto.generateKeyPairSync('ed25519')
|
||||
log.trace('New ed25519 keyPair:', keyPair)
|
||||
return keyPair
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message, [e])
|
||||
}
|
||||
}
|
||||
|
||||
const generateEncryptionKey = (): string => {
|
||||
try {
|
||||
const keyLength = 32 // Length of the encryption key in bytes
|
||||
const encryptionKey = QuickCrypto.randomBytes(keyLength)
|
||||
const uint8Array = new Uint8Array(encryptionKey)
|
||||
const stringKey = fromByteArray(uint8Array)
|
||||
const base64Key = btoa(stringKey)
|
||||
|
||||
log.trace('New Base64 encryptionKey created:', base64Key)
|
||||
|
||||
return base64Key
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message, [e])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save keypair to KeyChain/KeyStore
|
||||
*
|
||||
* @param keyPair The key to fetch.
|
||||
*/
|
||||
const saveKeyPair = async function (
|
||||
keyPair: {publicKey: string; privateKey: string},
|
||||
service: KeyChainServiceName,
|
||||
): Promise<_Keychain.Result | false> {
|
||||
try {
|
||||
const result = await _Keychain.setGenericPassword(
|
||||
keyPair.publicKey,
|
||||
keyPair.privateKey,
|
||||
{service},
|
||||
)
|
||||
|
||||
log.trace('Saved new keypair to the KeyChain')
|
||||
|
||||
return result
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message, [e])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads keypair from KeyChain/KeyStore
|
||||
*
|
||||
*
|
||||
*/
|
||||
const loadKeyPair = async function (
|
||||
service: KeyChainServiceName,
|
||||
): Promise<{publicKey: string; privateKey: string} | undefined> {
|
||||
try {
|
||||
const result = await _Keychain.getGenericPassword({service})
|
||||
|
||||
if (result) {
|
||||
const keyPair = {publicKey: result.username, privateKey: result.password}
|
||||
return keyPair
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads keypair from KeyChain/KeyStore
|
||||
*
|
||||
*
|
||||
*/
|
||||
const loadEncryptionKey = async function (
|
||||
service: KeyChainServiceName,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
log.trace('Load encryptionKey from KeyChain start')
|
||||
const result = await _Keychain.getGenericPassword({service})
|
||||
|
||||
if (result) {
|
||||
const key = result.password
|
||||
log.trace('Loaded existing encryptionKey from the KeyChain')
|
||||
return key
|
||||
}
|
||||
|
||||
log.trace('Did not find existing encryptionKey in the KeyChain')
|
||||
return undefined
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads keypair from KeyChain/KeyStore
|
||||
*
|
||||
* @param keyPair The key to fetch.
|
||||
*/
|
||||
const saveEncryptionKey = async function (
|
||||
key: string,
|
||||
service: KeyChainServiceName,
|
||||
): Promise<_Keychain.Result | false> {
|
||||
try {
|
||||
const result = await _Keychain.setGenericPassword(service, key, {service})
|
||||
|
||||
log.trace('Saved encryptionKey to the KeyChain')
|
||||
|
||||
return result
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message, [e])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes keypair from KeyChain/KeyStore
|
||||
*
|
||||
* @param key The key to kill.
|
||||
*/
|
||||
const removeKey = async function (
|
||||
service: KeyChainServiceName,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await _Keychain.resetGenericPassword({service})
|
||||
return result
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const KeyChain = {
|
||||
generateKeyPair,
|
||||
generateEncryptionKey,
|
||||
saveKeyPair,
|
||||
loadKeyPair,
|
||||
loadEncryptionKey,
|
||||
saveEncryptionKey,
|
||||
removeKey,
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
import {log} from '../utils/logger'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {KeyChain, KeyChainServiceName} from './keyChain'
|
||||
|
||||
let _storage: MMKV | undefined
|
||||
let _encryptionKey: string | undefined
|
||||
|
||||
const initEncryption = async function (): Promise<void> {
|
||||
if (!_encryptionKey) {
|
||||
_encryptionKey = await getOrCreateEncryptionKey()
|
||||
getInstance() // Instantiate the MMKV instance with the encryption key
|
||||
}
|
||||
}
|
||||
|
||||
const getInstance = function () {
|
||||
if (!_storage) {
|
||||
if (_encryptionKey) {
|
||||
_storage = new MMKV({
|
||||
id: 'storage-v1',
|
||||
encryptionKey: _encryptionKey,
|
||||
})
|
||||
|
||||
log.info('MMKV encrypted storage initialized')
|
||||
} else {
|
||||
_storage = new MMKV({
|
||||
id: 'storage-v1',
|
||||
})
|
||||
|
||||
log.info('MMKV storage initialized')
|
||||
}
|
||||
}
|
||||
|
||||
return _storage
|
||||
}
|
||||
|
||||
const getOrCreateEncryptionKey = async function (): Promise<string> {
|
||||
try {
|
||||
let key: string | null = null
|
||||
|
||||
key = (await KeyChain.loadEncryptionKey(KeyChainServiceName.MMKV)) as string
|
||||
|
||||
if (!key) {
|
||||
key = KeyChain.generateEncryptionKey() as string
|
||||
await KeyChain.saveEncryptionKey(key, KeyChainServiceName.MMKV)
|
||||
|
||||
log.info(
|
||||
'Created and saved new encryption key',
|
||||
[],
|
||||
'getOrCreateEncryptionKey',
|
||||
)
|
||||
}
|
||||
|
||||
return key
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message, [e.toString()])
|
||||
}
|
||||
}
|
||||
|
||||
const recryptStorage = async function (): Promise<boolean> {
|
||||
try {
|
||||
const storage = getInstance()
|
||||
|
||||
if (_encryptionKey) {
|
||||
storage.recrypt(undefined)
|
||||
_encryptionKey = undefined
|
||||
|
||||
log.info('Storage encryption has been removed', [], 'recryptStorage')
|
||||
return false
|
||||
}
|
||||
|
||||
const key = await getOrCreateEncryptionKey()
|
||||
|
||||
storage.recrypt(key)
|
||||
_encryptionKey = key
|
||||
|
||||
log.info('Storage has been encrypted', [], 'recryptStorage')
|
||||
return true
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message, [e.toString()])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a string from storage.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
*/
|
||||
const loadString = function (key: string): string | undefined {
|
||||
try {
|
||||
const storage = getInstance()
|
||||
return storage.getString(key)
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a string to storage.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
* @param value The value to store.
|
||||
*/
|
||||
const saveString = function (key: string, value: string): boolean {
|
||||
try {
|
||||
const storage = getInstance()
|
||||
storage.set(key, value)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a number to storage.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
* @param value The value to store.
|
||||
*/
|
||||
const saveNumber = function (key: string, value: number): boolean {
|
||||
try {
|
||||
const storage = getInstance()
|
||||
storage.set(key, value)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a boolean to storage.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
* @param value The value to store.
|
||||
*/
|
||||
const saveBoolean = function (key: string, value: boolean): boolean {
|
||||
try {
|
||||
const storage = getInstance()
|
||||
storage.set(key, value)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads something from storage and runs it thru JSON.parse.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
*/
|
||||
const load = function (key: string): any | undefined {
|
||||
try {
|
||||
const storage = getInstance()
|
||||
const serialized = storage.getString(key)
|
||||
|
||||
if (serialized) {
|
||||
return JSON.parse(serialized as string)
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an object to storage.
|
||||
*
|
||||
* @param key The key to fetch.
|
||||
* @param value The value to store.
|
||||
*/
|
||||
const save = function (key: string, value: any): boolean {
|
||||
log.trace('MMKV save start')
|
||||
try {
|
||||
const storage = getInstance()
|
||||
storage.set(key, JSON.stringify(value))
|
||||
return true
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes something from storage.
|
||||
*
|
||||
* @param key The key to kill.
|
||||
*/
|
||||
const remove = function (key: string): void {
|
||||
try {
|
||||
const storage = getInstance()
|
||||
storage.delete(key)
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Burn it all to the ground.
|
||||
*/
|
||||
const clearAll = function (): void {
|
||||
try {
|
||||
const storage = getInstance()
|
||||
storage.clearAll()
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const MMKVStorage = {
|
||||
initEncryption,
|
||||
getOrCreateEncryptionKey,
|
||||
recryptStorage,
|
||||
loadString,
|
||||
saveString,
|
||||
saveNumber,
|
||||
saveBoolean,
|
||||
load,
|
||||
save,
|
||||
remove,
|
||||
clearAll,
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
import {
|
||||
QuickSQLiteConnection,
|
||||
open,
|
||||
SQLBatchTuple,
|
||||
} from 'react-native-quick-sqlite'
|
||||
import {Proof} from '../models/Proof'
|
||||
import {
|
||||
Transaction,
|
||||
TransactionRecord,
|
||||
TransactionStatus,
|
||||
} from '../models/Transaction'
|
||||
import {UserSettings} from '../models/UserSettingsStore'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {log} from '../utils/logger'
|
||||
import {BackupProof} from '../models/Proof'
|
||||
|
||||
let _db: QuickSQLiteConnection
|
||||
|
||||
const _dbVersion = 1 // Update this if db changes require migrations
|
||||
|
||||
const getInstance = function () {
|
||||
if (!_db) {
|
||||
// 1. creates database
|
||||
_db = _createDatabaseInstance() as QuickSQLiteConnection
|
||||
|
||||
// 2. Runs possible migrations and sets version
|
||||
_createOrUpdateSchema(_db)
|
||||
}
|
||||
|
||||
return _db
|
||||
}
|
||||
|
||||
const _createDatabaseInstance = function () {
|
||||
try {
|
||||
const instance = open({name: 'minibits.db'})
|
||||
return instance as QuickSQLiteConnection
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not create or open database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const _createOrUpdateSchema = function (db: QuickSQLiteConnection) {
|
||||
const creationQueries = [
|
||||
[
|
||||
`CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
type TEXT,
|
||||
amount INTEGER,
|
||||
fee INTEGER,
|
||||
data TEXT,
|
||||
sentFrom TEXT,
|
||||
memo TEXT,
|
||||
balanceAfter INTEGER,
|
||||
noteToSelf TEXT,
|
||||
tags TEXT,
|
||||
status TEXT,
|
||||
createdAt TEXT
|
||||
)`,
|
||||
],
|
||||
[
|
||||
`CREATE TABLE IF NOT EXISTS usersettings (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
isOnboarded BOOLEAN,
|
||||
isStorageEncrypted BOOLEAN,
|
||||
isLocalBackupOn BOOLEAN,
|
||||
createdAt TEXT
|
||||
)`,
|
||||
],
|
||||
[
|
||||
`CREATE TABLE IF NOT EXISTS proofs (
|
||||
id TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
secret TEXT PRIMARY KEY NOT NULL,
|
||||
C TEXT NOT NULL,
|
||||
tId INTEGER,
|
||||
isPending BOOLEAN,
|
||||
isSpent BOOLEAN,
|
||||
updatedAt TEXT
|
||||
)`,
|
||||
],
|
||||
[
|
||||
`CREATE TABLE IF NOT EXISTS dbversion (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
version INTEGER,
|
||||
createdAt TEXT
|
||||
)`,
|
||||
],
|
||||
] as SQLBatchTuple[]
|
||||
|
||||
try {
|
||||
const {rowsAffected} = db.executeBatch(creationQueries)
|
||||
|
||||
if (rowsAffected && rowsAffected > 0) {
|
||||
log.info('New database schema created')
|
||||
}
|
||||
|
||||
// Returns undefined on first run
|
||||
const {version} = getDatabaseVersion()
|
||||
|
||||
// Trigger migrations only if there is no version on first run or versions mismatch
|
||||
if (!version || version < _dbVersion) {
|
||||
_runMigrations(db)
|
||||
}
|
||||
|
||||
log.info('Device database version:', version)
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not create or update database schema',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Run database migrations in case on device version of schema is not yet set or outdated
|
||||
|
||||
const _runMigrations = function (db: QuickSQLiteConnection) {
|
||||
const now = new Date()
|
||||
const {version} = getDatabaseVersion()
|
||||
let currentVersion = version
|
||||
|
||||
let migrationQueries: SQLBatchTuple[] = []
|
||||
|
||||
// Database migrations sequence based on local version numbers
|
||||
if (currentVersion && currentVersion < 2) {
|
||||
log.trace(
|
||||
`Preparing database migrations from version ${currentVersion} -> 2`,
|
||||
)
|
||||
|
||||
/* migrationQueries.push([
|
||||
`ALTER TABLE usersettings
|
||||
ADD COLUMN isLocalBackupOn INTEGER default 1`,
|
||||
]) */
|
||||
|
||||
currentVersion = 2
|
||||
log.info(`Prepared database migrations to version ${currentVersion}`)
|
||||
}
|
||||
|
||||
// On first run or after migrations, this inserts up to date version
|
||||
migrationQueries.push([
|
||||
`INSERT OR REPLACE INTO dbversion (id, version, createdAt)
|
||||
VALUES (?, ?, ?)`,
|
||||
[1, _dbVersion, now.toISOString()],
|
||||
])
|
||||
|
||||
try {
|
||||
const {rowsAffected} = db.executeBatch(migrationQueries)
|
||||
|
||||
if (rowsAffected && rowsAffected > 0) {
|
||||
log.info(`Completed database migrations to version ${_dbVersion}`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// TODO track
|
||||
log.error(
|
||||
Err.DATABASE_ERROR,
|
||||
'Error when executing rootStore migrations',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanAll = function () {
|
||||
const dropQueries = [
|
||||
['DROP TABLE transactions'],
|
||||
['DROP TABLE usersettings'],
|
||||
['DROP TABLE proofs'],
|
||||
['DROP TABLE dbversion'],
|
||||
] as SQLBatchTuple[]
|
||||
|
||||
try {
|
||||
const db = getInstance()
|
||||
const {rowsAffected} = db.executeBatch(dropQueries)
|
||||
|
||||
if (rowsAffected && rowsAffected > 0) {
|
||||
log.info('Database tables were deleted')
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not delete database schema',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Exported functions
|
||||
*/
|
||||
|
||||
const getDatabaseVersion = function (): {version: number | undefined} {
|
||||
try {
|
||||
const query = `
|
||||
SELECT version FROM dbVersion
|
||||
`
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(query)
|
||||
|
||||
if (rows?.length && rows.length > 0) {
|
||||
return rows?.item(0)
|
||||
}
|
||||
|
||||
return {version: undefined}
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not get database version',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* User settings
|
||||
*/
|
||||
|
||||
const getUserSettings = function (): UserSettings {
|
||||
try {
|
||||
const query = `
|
||||
SELECT * FROM usersettings LIMIT 1
|
||||
`
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(query)
|
||||
|
||||
if (!rows?.item(0)) {
|
||||
const defaultSettings = updateUserSettings({
|
||||
isOnboarded: 0,
|
||||
isStorageEncrypted: 0,
|
||||
isLocalBackupOn: 1,
|
||||
})
|
||||
log.info('Stored default user settings in the database')
|
||||
return defaultSettings
|
||||
}
|
||||
|
||||
return rows.item(0)
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not get user settings',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updateUserSettings = function (settings: UserSettings): UserSettings {
|
||||
try {
|
||||
const now = new Date()
|
||||
const {isOnboarded, isStorageEncrypted, isLocalBackupOn} = settings
|
||||
|
||||
const query = `
|
||||
INSERT OR REPLACE INTO usersettings (id, isOnboarded, isStorageEncrypted, isLocalBackupOn, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
const params = [
|
||||
1,
|
||||
isOnboarded,
|
||||
isStorageEncrypted,
|
||||
isLocalBackupOn,
|
||||
now.toISOString(),
|
||||
]
|
||||
|
||||
const db = getInstance()
|
||||
db.execute(query, params)
|
||||
|
||||
log.info('User settings updated in the database')
|
||||
|
||||
const updated = getUserSettings()
|
||||
return updated
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not update user settings',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Transactions
|
||||
*/
|
||||
const getTransactionById = function (id: number) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT * FROM transactions WHERE id = ?
|
||||
`
|
||||
|
||||
const params = [id]
|
||||
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(query, params)
|
||||
|
||||
return rows?.item(0) as TransactionRecord
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, 'Transaction not found', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const addTransactionAsync = async function (tx: Transaction) {
|
||||
try {
|
||||
const {type, amount, data, memo, status} = tx
|
||||
const now = new Date()
|
||||
|
||||
const query = `
|
||||
INSERT INTO transactions (type, amount, data, memo, status, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
const params = [type, amount, data, memo, status, now.toISOString()]
|
||||
|
||||
const db = getInstance()
|
||||
const result = await db.executeAsync(query, params)
|
||||
|
||||
log.info(
|
||||
'New transaction added to the database with id ',
|
||||
result.insertId,
|
||||
'addTransactionAsync',
|
||||
)
|
||||
|
||||
const newTx = getTransactionById(result.insertId as number)
|
||||
|
||||
return newTx as TransactionRecord
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not store transaction in the database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updateStatusAsync = async function (
|
||||
id: number,
|
||||
status: TransactionStatus,
|
||||
data: string,
|
||||
) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE transactions
|
||||
SET status = ?, data = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
const params = [status, data, id]
|
||||
|
||||
const db = getInstance()
|
||||
await db.executeAsync(query, params)
|
||||
|
||||
log.info(
|
||||
'Transaction status and data updated in the database',
|
||||
[],
|
||||
'updateStatusAsync',
|
||||
)
|
||||
|
||||
const updatedTx = getTransactionById(id as number)
|
||||
|
||||
return updatedTx as TransactionRecord
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not update transaction status in database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// This updates status and appends data to the existing transaction data
|
||||
const updateStatusesAsync = async function (
|
||||
transactionIds: number[],
|
||||
status: TransactionStatus,
|
||||
data: string,
|
||||
) {
|
||||
const transactionIdsString = transactionIds.join(',')
|
||||
|
||||
const selectQuery = `
|
||||
SELECT data
|
||||
FROM transactions
|
||||
WHERE id IN (${transactionIdsString})
|
||||
`
|
||||
|
||||
try {
|
||||
const db = getInstance()
|
||||
const result1 = await db.executeAsync(selectQuery)
|
||||
|
||||
if (!result1.rows) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedDataArray = []
|
||||
|
||||
// We prepare appended transaction data for each transaction retrieved into array
|
||||
for (const row of result1.rows?._array) {
|
||||
const currentData = row.data
|
||||
const updatedData = currentData.slice(0, -1) + ', ' + data + ']'
|
||||
updatedDataArray.push(updatedData)
|
||||
}
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE transactions
|
||||
SET status = ?, data = ?
|
||||
WHERE id IN (${transactionIdsString})
|
||||
`
|
||||
// We update one by one from the array
|
||||
const params = [status, updatedDataArray.join(',')]
|
||||
|
||||
const result2 = await _db.executeAsync(updateQuery, params)
|
||||
|
||||
log.trace(
|
||||
'Transaction statuses and data updated. Number of updates:',
|
||||
result2.rowsAffected,
|
||||
'updateStatusesAsync',
|
||||
)
|
||||
|
||||
return result2
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not update transaction statuses in the database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updateBalanceAfterAsync = async function (
|
||||
id: number,
|
||||
balanceAfter: number,
|
||||
) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE transactions
|
||||
SET balanceAfter = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
const params = [balanceAfter, id]
|
||||
|
||||
const db = getInstance()
|
||||
await db.executeAsync(query, params)
|
||||
|
||||
log.info('Transaction balanceAfter updated in the database')
|
||||
|
||||
const updatedTx = getTransactionById(id as number)
|
||||
|
||||
return updatedTx as TransactionRecord
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not update transaction balanceAfter in database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updatFeeAsync = async function (id: number, fee: number) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE transactions
|
||||
SET fee = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
const params = [fee, id]
|
||||
|
||||
const db = getInstance()
|
||||
await db.executeAsync(query, params)
|
||||
|
||||
log.info('Transaction fee updated in the database')
|
||||
|
||||
const updatedTx = getTransactionById(id as number)
|
||||
|
||||
return updatedTx as TransactionRecord
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not update transaction fee in the database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updateReceivedAmountAsync = async function (id: number, amount: number) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE transactions
|
||||
SET amount = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
const params = [amount, id]
|
||||
|
||||
const db = getInstance()
|
||||
await db.executeAsync(query, params)
|
||||
|
||||
log.info('Transaction received amount updated in the database')
|
||||
|
||||
const updatedTx = getTransactionById(id as number)
|
||||
|
||||
return updatedTx as TransactionRecord
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not update transaction received amount in database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updateNoteAsync = async function (id: number, note: string) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE transactions
|
||||
SET noteToSelf = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
const params = [note, id]
|
||||
|
||||
const db = getInstance()
|
||||
await db.executeAsync(query, params)
|
||||
|
||||
log.info('Transaction note updated in the database')
|
||||
|
||||
const updatedTx = getTransactionById(id as number)
|
||||
|
||||
return updatedTx as TransactionRecord
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not update transaction note in database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* const cleanTransactionData = async function (transactionIds: number[]) {
|
||||
try {
|
||||
|
||||
const transactionIdsString = transactionIds.join(',')
|
||||
|
||||
const query = `
|
||||
UPDATE transactions
|
||||
SET data = ?
|
||||
WHERE id IN (${transactionIdsString})
|
||||
`
|
||||
const params = ['[]']
|
||||
|
||||
const _db = getInstance()
|
||||
const result = await _db.executeAsync(query, params)
|
||||
|
||||
log.info('cleanTransactionData executed in the database')
|
||||
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, 'Could not cleanTransactionData in database', e.message)
|
||||
}
|
||||
} */
|
||||
|
||||
const getTransactionsAsync = async function (limit: number, offset: number) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM transactions
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
const params = [limit, offset]
|
||||
|
||||
const db = getInstance()
|
||||
const {rows} = await db.executeAsync(query, params)
|
||||
|
||||
return rows
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Transactions could not be retrieved from the database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getPendingAmount = function () {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
SUM(amount)
|
||||
FROM transactions
|
||||
WHERE status = ?
|
||||
`
|
||||
const params = [TransactionStatus.PENDING]
|
||||
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(query, params)
|
||||
|
||||
return rows?.item(0)['SUM(amount)']
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, 'Transaction not found', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Proofs - backup of store model
|
||||
*/
|
||||
const addOrUpdateProof = function (
|
||||
proof: Proof,
|
||||
isPending: boolean = false,
|
||||
isSpent: boolean = false,
|
||||
) {
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
const query = `
|
||||
INSERT OR REPLACE INTO proofs (id, amount, secret, C, tId, isPending, isSpent, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
const params = [
|
||||
proof.id,
|
||||
proof.amount,
|
||||
proof.secret,
|
||||
proof.C,
|
||||
proof.tId,
|
||||
isPending,
|
||||
isSpent,
|
||||
now.toISOString(),
|
||||
]
|
||||
|
||||
const db = getInstance()
|
||||
const result = db.execute(query, params)
|
||||
|
||||
log.info(
|
||||
`${
|
||||
isPending ? ' Pending' : ''
|
||||
} proof added or updated in the database with id`,
|
||||
result.insertId,
|
||||
)
|
||||
|
||||
const newProof = getProofById(result.insertId as number)
|
||||
|
||||
return newProof as BackupProof
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not store proof into the database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const addOrUpdateProofs = function (
|
||||
proofs: Proof[],
|
||||
isPending: boolean = false,
|
||||
isSpent: boolean = false,
|
||||
): number | undefined {
|
||||
try {
|
||||
const now = new Date()
|
||||
let insertQueries: SQLBatchTuple[] = []
|
||||
|
||||
for (const proof of proofs) {
|
||||
insertQueries.push([
|
||||
` INSERT OR REPLACE INTO proofs (id, amount, secret, C, tId, isPending, isSpent, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
proof.id,
|
||||
proof.amount,
|
||||
proof.secret,
|
||||
proof.C,
|
||||
proof.tId,
|
||||
isPending,
|
||||
isSpent,
|
||||
now.toISOString(),
|
||||
],
|
||||
])
|
||||
}
|
||||
|
||||
// Execute the batch of SQL statements
|
||||
const db = getInstance()
|
||||
const {rowsAffected} = db.executeBatch(insertQueries)
|
||||
|
||||
log.info(
|
||||
`${rowsAffected}${
|
||||
isPending ? ' pending' : ''
|
||||
} proofs were added or updated in the database`,
|
||||
)
|
||||
|
||||
return rowsAffected
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not store proofs into the database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAllProofs = async function () {
|
||||
try {
|
||||
const query = `
|
||||
DELETE FROM proofs
|
||||
`
|
||||
const db = getInstance()
|
||||
db.execute(query)
|
||||
|
||||
log.info('Proofs were removed from the database.')
|
||||
|
||||
return true
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not remove proofs from the database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getProofById = function (id: number) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT * FROM proofs WHERE id = ?
|
||||
`
|
||||
const params = [id]
|
||||
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(query, params)
|
||||
|
||||
return rows?.item(0) as BackupProof
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.DATABASE_ERROR, 'proof not found', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const getProofs = function (
|
||||
limit: number,
|
||||
offset: number,
|
||||
isPending: boolean = false,
|
||||
isSpent: boolean = false,
|
||||
): BackupProof[] {
|
||||
let query: string = ''
|
||||
|
||||
try {
|
||||
query = `
|
||||
SELECT *
|
||||
FROM proofs
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
if (isPending) {
|
||||
query = `
|
||||
SELECT *
|
||||
FROM proofs
|
||||
WHERE isPending = 1
|
||||
AND isSpent = 0
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
}
|
||||
if (isSpent) {
|
||||
query = `
|
||||
SELECT *
|
||||
FROM proofs
|
||||
WHERE isSpent = 1
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
}
|
||||
|
||||
const params = [limit, offset]
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(query, params)
|
||||
|
||||
return rows?._array as BackupProof[]
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Proofs could not be retrieved from the database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getProofsByTransaction = function (transactionId: number): BackupProof[] {
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM proofs
|
||||
WHERE tId = ?
|
||||
`
|
||||
const params = [transactionId]
|
||||
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(query, params)
|
||||
|
||||
return rows?._array as BackupProof[]
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Proofs could not be retrieved from the database',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const Database = {
|
||||
getInstance,
|
||||
getDatabaseVersion,
|
||||
cleanAll,
|
||||
getUserSettings,
|
||||
updateUserSettings,
|
||||
getTransactionById,
|
||||
addTransactionAsync,
|
||||
updateStatusAsync,
|
||||
updateStatusesAsync,
|
||||
updateBalanceAfterAsync,
|
||||
updatFeeAsync,
|
||||
updateReceivedAmountAsync,
|
||||
updateNoteAsync,
|
||||
getTransactionsAsync,
|
||||
getPendingAmount,
|
||||
addOrUpdateProof,
|
||||
addOrUpdateProofs,
|
||||
removeAllProofs,
|
||||
getProofById,
|
||||
getProofs,
|
||||
getProofsByTransaction,
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage"
|
||||
import { load, loadString, save, saveString, clear, remove } from "./storage"
|
||||
|
||||
// fixtures
|
||||
const VALUE_OBJECT = { x: 1 }
|
||||
const VALUE_STRING = JSON.stringify(VALUE_OBJECT)
|
||||
|
||||
beforeEach(() => (AsyncStorage.getItem as jest.Mock).mockReturnValue(Promise.resolve(VALUE_STRING)))
|
||||
afterEach(() => jest.clearAllMocks())
|
||||
|
||||
test("load", async () => {
|
||||
const value = await load("something")
|
||||
expect(value).toEqual(JSON.parse(VALUE_STRING))
|
||||
})
|
||||
|
||||
test("loadString", async () => {
|
||||
const value = await loadString("something")
|
||||
expect(value).toEqual(VALUE_STRING)
|
||||
})
|
||||
|
||||
test("save", async () => {
|
||||
await save("something", VALUE_OBJECT)
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith("something", VALUE_STRING)
|
||||
})
|
||||
|
||||
test("saveString", async () => {
|
||||
await saveString("something", VALUE_STRING)
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith("something", VALUE_STRING)
|
||||
})
|
||||
|
||||
test("remove", async () => {
|
||||
await remove("something")
|
||||
expect(AsyncStorage.removeItem).toHaveBeenCalledWith("something")
|
||||
})
|
||||
|
||||
test("clear", async () => {
|
||||
await clear()
|
||||
expect(AsyncStorage.clear).toHaveBeenCalledWith()
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
const palette = {
|
||||
neutral100: '#FFFFFF',
|
||||
neutral200: '#F4F4F4',
|
||||
neutral300: '#E6E6E6',
|
||||
neutral400: '#B6ACA6',
|
||||
neutral500: '#978F8A',
|
||||
neutral600: '#564E4A',
|
||||
neutral700: '#2e2e2e',
|
||||
neutral800: '#232323',
|
||||
neutral900: '#050505',
|
||||
|
||||
primary100: '#D6E4FF',
|
||||
primary200: '#ADC8FF',
|
||||
primary300: '#84A9FF',
|
||||
primary400: '#3680FA',
|
||||
primary500: '#2371F4',
|
||||
primary600: '#254EDB',
|
||||
primary700: '#1939B7',
|
||||
primary800: '#102693',
|
||||
primary900: '#091A7A',
|
||||
|
||||
secondary100: '#54ACE7',
|
||||
secondary200: '#318BC8',
|
||||
secondary300: '#1A71AC',
|
||||
secondary400: '#0E629B',
|
||||
secondary500: '#025287',
|
||||
|
||||
success100: '#78C670',
|
||||
success200: '#599D52',
|
||||
success300: '#3E7A38',
|
||||
|
||||
accent100: '#FDBFD3',
|
||||
accent200: '#F9D192',
|
||||
accent300: '#F7C577',
|
||||
accent400: '#FFBB50',
|
||||
accent500: '#DEA144',
|
||||
|
||||
angry100: '#F2D6CD',
|
||||
angry500: '#C03403',
|
||||
|
||||
overlay20: 'rgba(25, 16, 21, 0.2)',
|
||||
overlay50: 'rgba(25, 16, 21, 0.5)',
|
||||
|
||||
blue100: '#75B4FB',
|
||||
blue200: '#818AFF',
|
||||
|
||||
orange200: '#FFAB51',
|
||||
|
||||
focus100: '#FB4E9E',
|
||||
focus200: '#F62586',
|
||||
focus300: '#CC0964',
|
||||
|
||||
iconBlue200: '#318BC8',
|
||||
iconBlue300: '#1A71AC',
|
||||
iconGreen200: '#599D52',
|
||||
iconGreen300: '#3E7A38',
|
||||
iconYellow300: '#F7C577',
|
||||
iconGreyBlue400: '#0E629B',
|
||||
iconMagenta200: '#F62586',
|
||||
} as const
|
||||
|
||||
export const colors = {
|
||||
/**
|
||||
* The palette is available to use, but prefer using the name.
|
||||
* This is only included for rare, one-off cases. Try to use
|
||||
* semantic names as much as possible.
|
||||
*/
|
||||
palette,
|
||||
light: {
|
||||
/**
|
||||
* A helper for making something see-thru.
|
||||
*/
|
||||
transparent: 'rgba(0, 0, 0, 0)',
|
||||
/**
|
||||
* The default text color in many components.
|
||||
*/
|
||||
text: palette.neutral800,
|
||||
/**
|
||||
* Secondary text information.
|
||||
*/
|
||||
textDim: palette.neutral500,
|
||||
/**
|
||||
* Color for amounts and balances.
|
||||
*/
|
||||
amount: palette.neutral800,
|
||||
/**
|
||||
* The default color of the screen background.
|
||||
*/
|
||||
background: palette.neutral200,
|
||||
/**
|
||||
* The default bg color of the primary button.
|
||||
*/
|
||||
button: palette.success300,
|
||||
/**
|
||||
* The default color of the header and status bar.
|
||||
*/
|
||||
header: palette.primary400,
|
||||
/**
|
||||
* The default color of the bottom menu.
|
||||
*/
|
||||
menu: palette.neutral200,
|
||||
/**
|
||||
* The default border color.
|
||||
*/
|
||||
border: palette.neutral400,
|
||||
/**
|
||||
* The main tinting color.
|
||||
*/
|
||||
tint: palette.primary400,
|
||||
/**
|
||||
* A subtle color used for lines.
|
||||
*/
|
||||
separator: palette.neutral200,
|
||||
/**
|
||||
* Error messages.
|
||||
*/
|
||||
error: palette.angry500,
|
||||
/**
|
||||
* Error Background.
|
||||
*
|
||||
*/
|
||||
errorBackground: palette.angry100,
|
||||
/**
|
||||
* Info Background.
|
||||
*
|
||||
*/
|
||||
info: palette.success100,
|
||||
/**
|
||||
* The default card color.
|
||||
*/
|
||||
card: palette.neutral100,
|
||||
},
|
||||
dark: {
|
||||
/**
|
||||
* The palette is available to use, but prefer using the name.
|
||||
* This is only included for rare, one-off cases. Try to use
|
||||
* semantic names as much as possible.
|
||||
*/
|
||||
palette,
|
||||
/**
|
||||
* A helper for making something see-thru.
|
||||
*/
|
||||
transparent: 'rgba(0, 0, 0, 0)',
|
||||
/**
|
||||
* The default text color in many components.
|
||||
*/
|
||||
text: palette.neutral200,
|
||||
/**
|
||||
* Secondary text information.
|
||||
*/
|
||||
textDim: palette.neutral500,
|
||||
/**
|
||||
* Color for amounts and balances.
|
||||
*/
|
||||
amount: palette.neutral200,
|
||||
/**
|
||||
* The default color of the screen background.
|
||||
*/
|
||||
background: palette.neutral700,
|
||||
/**
|
||||
* The default bg color of the primary button.
|
||||
*/
|
||||
button: palette.success200,
|
||||
/**
|
||||
* The default color of the header and status bar.
|
||||
*/
|
||||
header: palette.primary500,
|
||||
/**
|
||||
* The default color of the bottom menu.
|
||||
*/
|
||||
menu: palette.neutral700,
|
||||
/**
|
||||
* The default border color.
|
||||
*/
|
||||
border: palette.neutral400,
|
||||
/**
|
||||
* The main tinting color.
|
||||
*/
|
||||
tint: palette.primary400,
|
||||
/**
|
||||
* A subtle color used for lines.
|
||||
*/
|
||||
separator: palette.neutral700,
|
||||
/**
|
||||
* Error messages.
|
||||
*/
|
||||
error: palette.angry500,
|
||||
/**
|
||||
* Error Background.
|
||||
*
|
||||
*/
|
||||
errorBackground: palette.angry100,
|
||||
/**
|
||||
* Info Background.
|
||||
*
|
||||
*/
|
||||
info: palette.success200,
|
||||
/**
|
||||
* The default card color.
|
||||
*/
|
||||
card: palette.neutral800,
|
||||
},
|
||||
}
|
||||
|
||||
export const getRandomIconColor = () => {
|
||||
const options = [
|
||||
palette.iconBlue200,
|
||||
palette.iconBlue300,
|
||||
palette.iconGreen200,
|
||||
palette.iconGreen300,
|
||||
palette.iconYellow300,
|
||||
]
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * options.length)
|
||||
return options[randomIndex]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './colors'
|
||||
export * from './spacing'
|
||||
export * from './typography'
|
||||
export * from './timing'
|
||||
export * from './useThemeColor'
|
||||
@@ -0,0 +1,20 @@
|
||||
import {Dimensions} from 'react-native'
|
||||
|
||||
/**
|
||||
Use these spacings for margins/paddings and other whitespace throughout your app.
|
||||
*/
|
||||
export const spacing = {
|
||||
micro: 2,
|
||||
tiny: 4,
|
||||
extraSmall: 8,
|
||||
small: 12,
|
||||
medium: 16,
|
||||
large: 24,
|
||||
extraLarge: 32,
|
||||
huge: 48,
|
||||
massive: 64,
|
||||
screenWidth: Dimensions.get('window').width,
|
||||
screenHeight: Dimensions.get('window').height,
|
||||
} as const
|
||||
|
||||
export type Spacing = keyof typeof spacing
|
||||
@@ -0,0 +1,6 @@
|
||||
export const timing = {
|
||||
/**
|
||||
* The duration (ms) for quick animations.
|
||||
*/
|
||||
quick: 300,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// TODO: write documentation about fonts and typography along with guides on how to add custom fonts in own
|
||||
// markdown file and add links from here
|
||||
|
||||
import {Platform} from 'react-native'
|
||||
|
||||
const fonts = {
|
||||
helveticaNeue: {
|
||||
// iOS only font.
|
||||
thin: 'HelveticaNeue-Thin',
|
||||
light: 'HelveticaNeue-Light',
|
||||
normal: 'Helvetica Neue',
|
||||
medium: 'HelveticaNeue-Medium',
|
||||
},
|
||||
courier: {
|
||||
// iOS only font.
|
||||
normal: 'Courier',
|
||||
},
|
||||
sansSerif: {
|
||||
// Android only font.
|
||||
thin: 'sans-serif-thin',
|
||||
light: 'sans-serif-light',
|
||||
normal: 'sans-serif',
|
||||
medium: 'sans-serif-medium',
|
||||
},
|
||||
monospace: {
|
||||
// Android only font.
|
||||
normal: 'monospace',
|
||||
},
|
||||
}
|
||||
|
||||
export const typography = {
|
||||
/**
|
||||
* The fonts are available to use, but prefer using the semantic name.
|
||||
*/
|
||||
fonts,
|
||||
/**
|
||||
* The primary font. Used in most places.
|
||||
*/
|
||||
primary: Platform.select({
|
||||
ios: fonts.helveticaNeue,
|
||||
android: fonts.sansSerif,
|
||||
}),
|
||||
/**
|
||||
* An alternate font used for perhaps titles and stuff.
|
||||
*/
|
||||
secondary: Platform.select({
|
||||
ios: fonts.helveticaNeue,
|
||||
android: fonts.sansSerif,
|
||||
}),
|
||||
/**
|
||||
* Lets get fancy with a monospace font!
|
||||
*/
|
||||
code: Platform.select({ios: fonts.courier, android: fonts.monospace}),
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {colors} from '../theme'
|
||||
import {
|
||||
ColorSchemeName,
|
||||
useColorScheme as _useColorScheme,
|
||||
ColorValue,
|
||||
} from 'react-native'
|
||||
|
||||
// The useColorScheme value is always either light or dark, but the built-in
|
||||
// type suggests that it can be null. This will not happen in practice, so this
|
||||
// makes it a bit easier to work with.
|
||||
export default function useColorScheme(): NonNullable<ColorSchemeName> {
|
||||
return _useColorScheme() as NonNullable<ColorSchemeName>
|
||||
}
|
||||
|
||||
export function useThemeColor(
|
||||
colorName: keyof typeof colors.light & keyof typeof colors.dark,
|
||||
) {
|
||||
const colorScheme = useColorScheme()
|
||||
return colors[colorScheme][colorName] as ColorValue
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {log} from './logger'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
export enum Err {
|
||||
CONNECTION_ERROR = 'CONNECTION_ERROR',
|
||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
||||
STORAGE_ERROR = 'STORAGE_ERROR',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
MINT_ERROR = 'MINT_ERROR',
|
||||
WALLET_ERROR = 'WALLET_ERROR',
|
||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
||||
TIMEOUT_ERROR = 'TIMEOUT_ERROR',
|
||||
NOTFOUND_ERROR = 'NOTFOUND_ERROR',
|
||||
ALREADY_EXISTS_ERROR = 'ALREADY_EXISTS_ERROR',
|
||||
UNAUTHORIZED_ERROR = 'UNAUTHORIZED_ERROR',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
KEYCHAIN_ERROR = 'KEYCHAIN_ERROR',
|
||||
POLLING_ERROR = 'POLLING_ERROR',
|
||||
}
|
||||
|
||||
export interface IAppError {
|
||||
code?: string
|
||||
name: Err
|
||||
message: string
|
||||
params?: any
|
||||
}
|
||||
|
||||
class AppError extends Error {
|
||||
public name: Err
|
||||
public message: string
|
||||
public params: any
|
||||
|
||||
constructor(name: Err = Err.UNKNOWN_ERROR, message: string, ...params: any) {
|
||||
super(...params)
|
||||
this.name = name
|
||||
this.message = message
|
||||
this.params = params
|
||||
|
||||
let caller = ''
|
||||
const error = new Error()
|
||||
const stackTrace = error.stack?.split('\n')
|
||||
|
||||
if (stackTrace) {
|
||||
const callerLine = stackTrace[2]
|
||||
caller = callerLine !== null ? callerLine.match(/at\s+(.*)\s+\(/)[1] : ''
|
||||
}
|
||||
|
||||
log.error(name, message, params, caller)
|
||||
|
||||
// Sentry trial
|
||||
if (!__DEV__) {
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
|
||||
Object.setPrototypeOf(this, new.target.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export default AppError
|
||||
@@ -0,0 +1,25 @@
|
||||
import {lightFormat} from 'date-fns'
|
||||
// import { i18n } from "../i18n"
|
||||
// import en from "date-fns/locale/en-US"
|
||||
|
||||
// TODO
|
||||
|
||||
/* type Options = Parameters<typeof format>[2]
|
||||
|
||||
const getLocale = (): Locale => {
|
||||
const locale = i18n.locale.split("-")[0]
|
||||
// return locale === "ar" ? ar : locale === "ko" ? ko : en
|
||||
return locale
|
||||
}
|
||||
|
||||
export const formatDate = (date: number | Date, dateFormat?: string, options?: Options) => {
|
||||
const locale = `${i18n.locale}` */
|
||||
|
||||
export const formatDate = function (date: number | Date) {
|
||||
return lightFormat(date, 'dd.MM.yyyy')
|
||||
}
|
||||
|
||||
export const SQLiteTimestampToDate = function (timestamp: string) {
|
||||
const timestampISO = `${timestamp.replace(' ', 'T')}Z`
|
||||
return new Date(timestampISO)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* sleep statement.
|
||||
*
|
||||
* @param ms The number of milliseconds to wait.
|
||||
*/
|
||||
export const delay = (ms: number) =>
|
||||
new Promise(resolve => setTimeout(resolve, ms))
|
||||
@@ -0,0 +1,7 @@
|
||||
declare module '@env' {
|
||||
export const APP_ENV: string
|
||||
export const LOG_LEVEL: string
|
||||
export const SENTRY_DSN: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
class EventEmitter {
|
||||
private events: {[eventName: string]: Function[]}
|
||||
|
||||
constructor() {
|
||||
this.events = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event.
|
||||
* @param eventName The name of the event to subscribe to.
|
||||
* @param handler The callback function to be called when the event is emitted.
|
||||
*/
|
||||
on(eventName: string, handler: Function) {
|
||||
if (!this.events[eventName]) {
|
||||
this.events[eventName] = []
|
||||
}
|
||||
|
||||
this.events[eventName].push(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event.
|
||||
* @param eventName The name of the event to emit.
|
||||
* @param args Additional arguments to pass to the event handlers.
|
||||
*/
|
||||
emit(eventName: string, ...args: any[]) {
|
||||
const handlers = this.events[eventName]
|
||||
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => handler(...args))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event.
|
||||
* @param eventName The name of the event to unsubscribe from.
|
||||
* @param handler The callback function to remove from the event handlers.
|
||||
*/
|
||||
off(eventName: string, handler: Function) {
|
||||
const handlers = this.events[eventName]
|
||||
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(handler)
|
||||
if (index !== -1) {
|
||||
handlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new EventEmitter()
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Ignore some yellowbox warnings. Some of these are for deprecated functions
|
||||
* that we haven't gotten around to replacing yet.
|
||||
*/
|
||||
import {LogBox} from 'react-native'
|
||||
|
||||
// prettier-ignore
|
||||
LogBox.ignoreLogs([
|
||||
'Require cycle:',
|
||||
])
|
||||
@@ -0,0 +1,128 @@
|
||||
import {lightFormat} from 'date-fns'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
import {Err} from './AppError'
|
||||
import {APP_ENV, LOG_LEVEL} from '@env'
|
||||
|
||||
export enum LogLevel {
|
||||
TRACE = 'TRACE',
|
||||
DEBUG = 'DEBUG',
|
||||
INFO = 'INFO',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
export enum Env {
|
||||
DEV = 'DEV',
|
||||
TEST = 'TEST',
|
||||
PROD = 'PROD',
|
||||
}
|
||||
|
||||
const _showTimestamps: boolean = true
|
||||
const _logLevel = LOG_LEVEL as LogLevel
|
||||
|
||||
const trace = function (message: any, params: any = [], caller: string = '') {
|
||||
if (APP_ENV === Env.PROD) return
|
||||
|
||||
let callerFunctionName = ''
|
||||
const error = new Error()
|
||||
const stackTrace = error.stack?.split('\n')
|
||||
|
||||
if (stackTrace) {
|
||||
const callerLine = stackTrace[2]
|
||||
callerFunctionName =
|
||||
callerLine !== null ? callerLine.match(/at\s+(.*)\s+\(/)[1] : ''
|
||||
|
||||
if(callerFunctionName === '?anon_0_' || callerFunctionName === 'anonymous') {
|
||||
callerFunctionName = caller
|
||||
}
|
||||
}
|
||||
|
||||
if (_logLevel === LogLevel.TRACE) {
|
||||
if (_showTimestamps) {
|
||||
const t = lightFormat(new Date(), 'HH:mm:ss:SSS')
|
||||
console.log(`[TRACE] ${t} [${callerFunctionName}]`, message, params)
|
||||
} else {
|
||||
console.log(`[TRACE] [${callerFunctionName}]`, message, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debug = function (
|
||||
message: string,
|
||||
params: any = [],
|
||||
caller: string = '',
|
||||
) {
|
||||
if (APP_ENV === Env.PROD) return
|
||||
|
||||
let callerFunctionName = ''
|
||||
const error = new Error()
|
||||
const stackTrace = error.stack?.split('\n')
|
||||
|
||||
if (stackTrace) {
|
||||
const callerLine = stackTrace[2]
|
||||
callerFunctionName = callerLine !== null ? callerLine.match(/at\s+(.*)\s+\(/)[1] : ''
|
||||
|
||||
if(callerFunctionName === '?anon_0_' || callerFunctionName === 'anonymous') {
|
||||
callerFunctionName = caller
|
||||
}
|
||||
}
|
||||
|
||||
if ([LogLevel.TRACE, LogLevel.DEBUG].includes(_logLevel)) {
|
||||
if (_showTimestamps) {
|
||||
const t = lightFormat(new Date(), 'HH:mm:ss:SSS')
|
||||
console.log(`[DEBUG] ${t} [${callerFunctionName}]`, message, params)
|
||||
} else {
|
||||
console.log(`[DEBUG] [${callerFunctionName}]`, message, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const info = function (message: string, params: any = [], caller: string = '') {
|
||||
let callerFunctionName = ''
|
||||
const error = new Error()
|
||||
const stackTrace = error.stack?.split('\n')
|
||||
|
||||
if (stackTrace) {
|
||||
const callerLine = stackTrace[2]
|
||||
callerFunctionName =
|
||||
callerLine !== null ? callerLine.match(/at\s+(.*)\s+\(/)[1] : ''
|
||||
|
||||
|
||||
if( callerFunctionName === '?anon_0_' || callerFunctionName === 'anonymous') {
|
||||
callerFunctionName = caller
|
||||
}
|
||||
}
|
||||
|
||||
if ([LogLevel.TRACE, LogLevel.DEBUG, LogLevel.INFO].includes(_logLevel)) {
|
||||
if (_showTimestamps) {
|
||||
const t = lightFormat(new Date(), 'HH:mm:ss:SSS')
|
||||
console.log(`[INFO] ${t} [${callerFunctionName}]`, message, params)
|
||||
} else {
|
||||
console.log(`[INFO] [${callerFunctionName}]`, message, params)
|
||||
}
|
||||
if (!__DEV__) {
|
||||
Sentry.captureMessage(message, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const error = function (
|
||||
name: Err,
|
||||
message: string,
|
||||
params: any = [],
|
||||
caller: string = '',
|
||||
) {
|
||||
if (_showTimestamps) {
|
||||
const t = lightFormat(new Date(), 'HH:mm:ss:SSS')
|
||||
console.error(`[${name}] ${t} [${caller}]`, message, params)
|
||||
} else {
|
||||
console.error(`[${name}] [${caller}]`, message, params)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const log = {
|
||||
trace,
|
||||
debug,
|
||||
info,
|
||||
error
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import numbro from 'numbro'
|
||||
|
||||
export const formatNumber = (amount: number | string, mantissa: number) => {
|
||||
return numbro(amount).format({
|
||||
mantissa,
|
||||
thousandSeparated: true,
|
||||
trimMantissa: true,
|
||||
})
|
||||
}
|
||||
|
||||
export const toNumber = (value: string) => {
|
||||
return numbro(value).value()
|
||||
}
|
||||
|
||||
export const round = (number: number, decimals: number) => {
|
||||
const factorOfTen = Math.pow(10, decimals)
|
||||
return Math.round(number * factorOfTen) / factorOfTen
|
||||
}
|
||||
|
||||
export const roundUp = (number: number, decimals: number) => {
|
||||
const factorOfTen = Math.pow(10, decimals)
|
||||
return Math.ceil(number * factorOfTen) / factorOfTen
|
||||
}
|
||||
|
||||
export const roundDown = (number: number, decimals: number) => {
|
||||
const factorOfTen = Math.pow(10, decimals)
|
||||
return Math.floor(number * factorOfTen) / factorOfTen
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {Linking} from 'react-native'
|
||||
|
||||
/**
|
||||
* Helper for opening a give URL in an external browser.
|
||||
*/
|
||||
export function openLinkInBrowser(url: string) {
|
||||
Linking.canOpenURL(url).then(canOpen => canOpen && Linking.openURL(url))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {log} from './logger'
|
||||
import {Err} from './AppError'
|
||||
|
||||
let shouldStop = false
|
||||
|
||||
export const poller = async (
|
||||
name: string,
|
||||
fnToExecute: () => Promise<any>,
|
||||
interval: number,
|
||||
maxPolls: number,
|
||||
maxErrors: number,
|
||||
): Promise<void> => {
|
||||
let pollCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
while (!shouldStop && pollCount < maxPolls && errorCount < maxErrors) {
|
||||
try {
|
||||
log.trace(`Poll count ${pollCount}`)
|
||||
await fnToExecute()
|
||||
pollCount++
|
||||
} catch (e: any) {
|
||||
log.error(Err.POLLING_ERROR, `Polling error for ${name}:`, e.message)
|
||||
errorCount++
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, interval))
|
||||
}
|
||||
}
|
||||
|
||||
export const stopPolling = () => {
|
||||
shouldStop = true
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, {useLayoutEffect} from 'react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {Header, HeaderProps} from '../components'
|
||||
|
||||
/**
|
||||
* A hook that can be used to easily set the Header of a react-navigation screen from within the screen's component.
|
||||
*
|
||||
*/
|
||||
export function useHeader(headerProps: HeaderProps, deps: any[] = []) {
|
||||
const navigation = useNavigation()
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShown: true,
|
||||
header: () => <Header {...headerProps} />,
|
||||
})
|
||||
}, deps)
|
||||
/* eslint-enable */
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {useEffect, useCallback, useRef} from 'react'
|
||||
|
||||
export function useIsMounted() {
|
||||
const isMounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true
|
||||
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useCallback(() => isMounted.current, [])
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {FlexStyle} from 'react-native'
|
||||
import {Edge, useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
export type ExtendedEdge = Edge | 'start' | 'end'
|
||||
|
||||
const propertySuffixMap = {
|
||||
top: 'Top',
|
||||
bottom: 'Bottom',
|
||||
left: 'Start',
|
||||
right: 'End',
|
||||
start: 'Start',
|
||||
end: 'End',
|
||||
}
|
||||
|
||||
const edgeInsetMap = {
|
||||
start: 'left',
|
||||
end: 'right',
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that can be used to create a safe-area-aware style object that can be passed directly to a View.
|
||||
*
|
||||
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Utils-useSafeAreaInsetsStyle.md)
|
||||
*/
|
||||
export function useSafeAreaInsetsStyle(
|
||||
safeAreaEdges: ExtendedEdge[] = [],
|
||||
property: 'padding' | 'margin' = 'padding',
|
||||
): Pick<
|
||||
FlexStyle,
|
||||
| 'marginBottom'
|
||||
| 'marginEnd'
|
||||
| 'marginStart'
|
||||
| 'marginTop'
|
||||
| 'paddingBottom'
|
||||
| 'paddingEnd'
|
||||
| 'paddingStart'
|
||||
| 'paddingTop'
|
||||
> {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return safeAreaEdges.reduce((acc, e) => {
|
||||
return {
|
||||
...acc,
|
||||
[`${property}${propertySuffixMap[e]}`]: insets[edgeInsetMap[e] ?? e],
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
Reference in New Issue
Block a user