Minibits source code - alpha version

This commit is contained in:
minibits-cash
2023-07-06 23:39:10 +02:00
parent 332e888793
commit 1555a2d2ef
98 changed files with 15796 additions and 125 deletions
-118
View File
@@ -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;
+12 -7
View File
@@ -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
View File
@@ -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
+72
View File
@@ -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]} />
}
+235
View File
@@ -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,
}
+190
View File
@@ -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 },
}
+299
View File
@@ -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 },
}
+219
View File
@@ -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 }
+51
View File
@@ -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>
</>
}
/>
)
}
+341
View File
@@ -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,
}
+205
View File
@@ -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,
}
+44
View File
@@ -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>
)
}
+268
View File
@@ -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,
}
+18
View File
@@ -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>
)
}
+236
View File
@@ -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",
}
+112
View File
@@ -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>,
}
+275
View File
@@ -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",
}
+655
View File
@@ -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,
}
+15
View File
@@ -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"
+26
View File
@@ -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
+10
View File
@@ -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/",
}
+10
View File
@@ -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",
}
+28
View File
@@ -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
View File
@@ -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;
+44
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
import "./i18n"
export * from "./i18n"
export * from "./translate"
+32
View File
@@ -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)
}
+53
View File
@@ -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> {}
+76
View File
@@ -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> {}
+63
View File
@@ -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> {}
+119
View File
@@ -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> {}
+40
View File
@@ -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> {}
+198
View File
@@ -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> {}
+36
View File
@@ -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> {}
+13
View File
@@ -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> {}
+24
View File
@@ -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> {}
+66
View File
@@ -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> {}
+248
View File
@@ -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> {}
+79
View File
@@ -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> {}
+10
View File
@@ -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)
}
+173
View File
@@ -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 }
} */
+83
View File
@@ -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
+30
View File
@@ -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
},
})
+4
View File
@@ -0,0 +1,4 @@
export * from "./RootStore"
export * from "./helpers/getRootStore"
export * from "./helpers/useStores"
export * from "./helpers/setupRootStore"
+93
View File
@@ -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>
)
})
+189
View File
@@ -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,
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./AppNavigator"
export * from "./TabsNavigator"
export * from "./navigationUtilities"
// export other navigators from here
+171
View File
@@ -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)
}
}
+228
View File
@@ -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,
}
+27
View File
@@ -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,
}
+197
View File
@@ -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,
}
+70
View File
@@ -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
)
}
}
+87
View File
@@ -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,
}
+402
View File
@@ -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,
}
+45
View File
@@ -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'}
/>
)})
+452
View File
@@ -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,
}
+464
View File
@@ -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',
}
+207
View File
@@ -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',
}
+174
View File
@@ -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,
}
+807
View File
@@ -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,
}
+154
View File
@@ -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,
}
+766
View File
@@ -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',
}
+932
View File
@@ -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',
}
+242
View File
@@ -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,
}
+590
View File
@@ -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,
}
+34
View File
@@ -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,
}
+513
View File
@@ -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,
}
+132
View File
@@ -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,
}
+22
View File
@@ -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'
+88
View File
@@ -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
} */
+311
View File
@@ -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]
}
}, [])
}
+267
View File
@@ -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,
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./cashuMintClient"
export * from "./asyncStorage"
export * from "./mmkvStorage"
export * from "./sqlite"
export * from "./keyChain"
export * from "./walletService"
+153
View File
@@ -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,
}
+221
View File
@@ -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,
}
+819
View File
@@ -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,
}
+39
View File
@@ -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
+216
View File
@@ -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]
}
+5
View File
@@ -0,0 +1,5 @@
export * from './colors'
export * from './spacing'
export * from './typography'
export * from './timing'
export * from './useThemeColor'
+20
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
export const timing = {
/**
* The duration (ms) for quick animations.
*/
quick: 300,
}
+54
View File
@@ -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}),
}
+20
View File
@@ -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
}
+59
View File
@@ -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
+25
View File
@@ -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)
}
+7
View File
@@ -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))
+7
View File
@@ -0,0 +1,7 @@
declare module '@env' {
export const APP_ENV: string
export const LOG_LEVEL: string
export const SENTRY_DSN: string
}
+51
View File
@@ -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()
+10
View File
@@ -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:',
])
+128
View File
@@ -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
}
+28
View File
@@ -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
}
+8
View File
@@ -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))
}
+32
View File
@@ -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
}
+20
View File
@@ -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 */
}
+15
View File
@@ -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, [])
}
+47
View File
@@ -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],
}
}, {})
}