Files
minibits_wallet/src/screens/MintInfoScreen.tsx
T

557 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { FC, useEffect, useMemo, useState } from 'react'
import { GetInfoResponse as _GetInfoResponse, SwapMethod } from '@cashu/cashu-ts'
import { isObj } from '@cashu/cashu-ts/src/utils'
import Clipboard from '@react-native-clipboard/clipboard'
import { observer } from 'mobx-react-lite'
import { getSnapshot } from 'mobx-state-tree'
import { DimensionValue, LayoutAnimation, Platform, TextStyle, UIManager, View, ViewStyle } from 'react-native'
import JSONTree from 'react-native-json-tree'
import {
$sizeStyles,
Button,
Card,
ErrorModal,
Icon,
IconTypes,
InfoModal,
ListItem,
Loading,
MintIcon,
Screen,
Text,
} from '../components'
import { AvatarHeader } from '../components/AvatarHeader'
import { CollapsibleText } from '../components/CollapsibleText'
import { translate } from '../i18n'
import { useStores } from '../models'
import { Mint, MintStatus } from '../models/Mint'
import { SettingsStackScreenProps } from '../navigation'
import { MintClient, log } from '../services'
import { colors, spacing, typography, useThemeColor } from '../theme'
import useColorScheme from '../theme/useThemeColor'
import AppError, { Err } from '../utils/AppError'
import { useHeader } from '../utils/useHeader'
import { CurrencySign } from './Wallet/CurrencySign'
import { SvgXml } from 'react-native-svg'
// cashu-ts currently does not type NUT15 (multipath payments) correctly
// relevant issue: https://github.com/cashubtc/cashu-ts/issues/142
// nut-15 spec: https://github.com/cashubtc/nuts/blob/main/15.md
type NUT15Entry = { method: string, unit: string, mpp: boolean }
type GetInfoResponse = _GetInfoResponse & {
nuts: {
15: Array<NUT15Entry>
}
}
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true)
}
interface DetailedNutInfo {
methods: Array<SwapMethod>;
disabled: boolean;
}
interface MethodLimit {
min?: number,
max?: number,
}
const iconMap: Partial<Record<keyof GetInfoResponse, IconTypes>> = {
'name': 'faTag',
'pubkey': 'faKey',
'version': 'faInfoCircle',
'contact': 'faAddressCard'
}
const contactIconMap: Record<string, IconTypes> = {
'email': 'faEnvelope',
'twitter': 'faTwitter',
'telegram': 'faTelegramPlane',
'discord': 'faDiscord',
'github': 'faGithub',
'reddit': 'faReddit',
'nostr': 'faCircleNodes'
}
function MOTDCard(props: {info: GetInfoResponse}) {
const textDim = useThemeColor('textDim')
return (
<Card
RightComponent={
<View style={{justifyContent: 'center'}}>
<Icon icon="faCircleExclamation" color={textDim} size={20} />
</View>
}
headingTx="mintInfo.motd"
HeadingTextProps={{style: [$sizeStyles.sm, {color: textDim}]}}
ContentComponent={
<Text style={{fontStyle: 'italic'}} text={props.info.motd} />
}
/>
)
}
function MintLimitsCard(props: { info: GetInfoResponse, limitInfo: ReturnType<typeof getMintLimits> }) {
if (props.limitInfo.mintSats === false && props.limitInfo.mintSats === false) return;
log.trace('MintLimtsCard', props.limitInfo)
const limitText = (m: MethodLimit) => (
typeof m.min !== 'undefined' && typeof m.max !== 'undefined' ? `${m.min} – ${m.max}`
: typeof m.min !== 'undefined' && typeof m.max === 'undefined' ? `min: ${m.min}`
: typeof m.max !== 'undefined' && typeof m.min === 'undefined' ? `max: ${m.max}`
: void 0
)
const LimitItem = (props: { m: MethodLimit, type: 'mint' | 'melt' }) => (<View style={$limitItem}>
<Icon
icon={props.type === 'mint' ? 'faCircleArrowUp' : 'faCircleArrowDown'}
color={colors.palette.success200}
size={16}
containerStyle={$nutIcon}
/>
<Text text={limitText(props.m)} />
</View>)
const textDim = useThemeColor('textDim')
return (<Card
headingTx="mintInfo.mintMeltHeading"
HeadingTextProps={{ style: [$sizeStyles.sm, { color: textDim }] }}
ContentComponent={<>
<View style={$limitItemWrapper}>
<Text text="Deposit (Mint)" style={{ width: '50%' }} size='xs' />
<Text text="Withdraw (Melt)" style={{ width: '50%' }} size='xs' />
</View>
<View style={$limitItemWrapper}>
<LimitItem m={props.limitInfo.mintSats as MethodLimit} type="mint" />
<LimitItem m={props.limitInfo.meltSats as MethodLimit} type="melt" />
</View>
</>}
/>)
}
function DescriptionCard(props: {info: GetInfoResponse}) {
const textDim = useThemeColor('textDim')
return (<Card
headingTx="mintInfo.descriptionHeading"
HeadingTextProps={{ style: [$sizeStyles.sm, { color: textDim }] }}
ContentComponent={
props.info && props.info.description ? (
<CollapsibleText
collapsed={true}
summary={props.info.description}
text={props.info?.description_long && props.info.description !== props.info.description_long
? props.info.description + '\n' + props.info.description_long
: ''
}
/>
) : (
<Text
style={{fontStyle: 'italic'}}
text={translate('mintInfo.emptyValueParam', {
param: translate('mintInfo.descriptionHeading'),
})}
/>
)
}
/>)
}
/** one-line item for NUT specifications and adjacent things like limits */
function NutItem(props: {
enabled: boolean,
nutNameNumber: string,
width?: DimensionValue
customIcon?: IconTypes,
customIconColor?: string,
}) {
return (
<View style={[$nutItem, { width: props?.width ?? 'auto'}]}>
<Icon
icon={props?.customIcon ?? (props.enabled ? 'faCheckCircle' : 'faXmark')}
color={props?.customIconColor ?? (props.enabled ? colors.palette.success200 : colors.palette.angry500)}
size={16}
containerStyle={$nutIcon}
/>
<Text size='xs'>NUT-{props.nutNameNumber}</Text>
</View>
)
}
function NutsCard(props: {info: GetInfoResponse}) {
const textDim = useThemeColor('textDim')
const supportedNutsDetailed: [string, DetailedNutInfo][] = []
const nutsSimple: [string, boolean][] = []
// detailed nuts are separated from simple ones if we want to show more info abt them in the future
for (const [nut, info] of Object.entries(props.info.nuts)) {
if (nut === '15' && (info as NUT15Entry[]).length > 0) {
// see https://github.com/cashubtc/nuts/blob/main/15.md - multipath payments
// in the future, it might be nice to show for which currencies are multipath payments supported
// for example by extending NutItem
nutsSimple.push(['15', (info as NUT15Entry[])[0]?.mpp ?? false])
continue;
}
if ('disabled' in info && info.disabled === false) { // detailed
supportedNutsDetailed.push([nut, info])
} else if ('supported' in info) { // simple
nutsSimple.push([nut, info.supported])
} else {
nutsSimple.push([nut, false]) // fallback or detailed but disabled nut
}
}
const smallNutCols = 4
return (
<Card
headingTx="mintInfo.nutsHeading"
HeadingTextProps={{style: [$sizeStyles.sm, {color: textDim}]}}
ContentComponent={
<View style={{flexDirection: 'row', flexWrap: 'wrap'}}>
{supportedNutsDetailed.map(([nut, info]) => (
<NutItem
nutNameNumber={nut}
enabled={true}
key={`detailed-${nut}`}
width={`${Math.round(100 / smallNutCols)}%`}
/>
))}
{nutsSimple.map(([nut, enabled]) => (
<NutItem
nutNameNumber={nut}
enabled={enabled}
key={`simple-${nut}`}
width={`${Math.round(100 / smallNutCols)}%`}
/>
))}
</View>
}
/>
)
}
function ContactCard(props: { info: GetInfoResponse, popupMessage: (msg: string) => void }) {
const textDim = useThemeColor('textDim')
let contacts = props.info.contact.filter(([k, v]) => k.trim() !== '') // filter out empty contacts
return (
<Card
headingTx="mintInfo.contactsHeading"
HeadingTextProps={{style: [$sizeStyles.sm, {color: textDim}]}}
ContentComponent={
contacts.length === 0 ? (
<Text
style={{fontStyle: 'italic'}}
text={translate('mintInfo.emptyValueParam', { param: translate('mintInfo.contactsHeading') })}
/>
) : (
<>
{contacts.map(([platform, user], index) => (
<ListItem
style={$contactListItem}
key={platform}
text={platform}
textStyle={$sizeStyles.xs}
LeftComponent={<Icon icon={platform in contactIconMap ? contactIconMap[platform] : 'faAddressBook'} color={textDim}/>}
RightComponent={<View style={{ width: spacing.screenWidth * 0.6 }}><Text text={user}/></View>}
topSeparator={index !== 0}
onLongPress={() => {
Clipboard.setString(user)
props.popupMessage(translate('common.copySuccessParam', { param: user }))
}}
/>
))}
</>
)
}
/>
)
}
/** don't render these because they're rendered in separate components */
const detailsHiddenKeys = new Set(['name', 'motd', 'description', 'description_long', 'nuts', 'contact'])
/** key-value pairs of details about the mint */
function MintInfoDetails(props: { info: GetInfoResponse, popupMessage: (msg: string) => void }) {
const iconColor = useThemeColor('textDim')
const items: React.JSX.Element[] = Object.entries(props.info)
.filter(([key, value]) => !(detailsHiddenKeys.has(key)))
.map(([key, value], index) => {
const missingComponent = <Text
style={{fontStyle: 'italic'}}
size="xs"
key={key}
text={translate('mintInfo.emptyValueParam', { param: key })}
/>
let stringValue = isObj(value) ? JSON.stringify(value) : value.toString()
let valueComponent = stringValue.trim() !== ''
? <Text size='xs' text={stringValue} />
: missingComponent;
const handleLongPress = () => {
if (stringValue.trim() === '') return;
Clipboard.setString(stringValue)
props.popupMessage(translate('common.copySuccessParam', { param: stringValue }))
}
// @ts-ignore no-implicit-any
const leftComponent = key in iconMap ? <Icon icon={iconMap[key]} color={iconColor}/> : void 0
// @ts-ignore no-implicit-any
return <ListItem
LeftComponent={leftComponent}
text={key}
textStyle={$sizeStyles.xs}
RightComponent={<View style={{ width: spacing.screenWidth * 0.6 }}>{valueComponent}</View>}
topSeparator={index !== 0}
key={key}
onLongPress={handleLongPress}
style={$listItem}
/>
})
return <>{items}</>
}
function getMintLimits(info: GetInfoResponse) {
// later this can be adjusted to show USD/other units as well. for now only shows limits if they are in sats
let mintSats: false | MethodLimit = false
let meltSats: false | MethodLimit = false
console.log('runs')
for (const method of info.nuts['4'].methods) {
if ((typeof method.min_amount !== 'undefined' || typeof method.max_amount !== 'undefined') && method.unit === 'sat') {
mintSats = { min: method.min_amount, max: method.max_amount }
break;
}
}
for (const method of info.nuts['5'].methods) {
if ((typeof method.min_amount !== 'undefined' || typeof method.max_amount !== 'undefined') && method.unit === 'sat') {
meltSats = { min: method.min_amount, max: method.max_amount }
break;
}
}
return {
mintSats,
meltSats,
any: mintSats || meltSats,
both: mintSats && meltSats,
}
}
export const MintInfoScreen: FC<SettingsStackScreenProps<'MintInfo'>> = observer(function MintInfoScreen(_props) {
const { navigation, route } = _props
useHeader({
leftIcon: 'faArrowLeft',
onLeftPress: () => {
navigation.goBack()
},
})
const { mintsStore } = useStores()
const [isLoading, setIsLoading] = useState(false)
const [mintInfo, setMintInfo] = useState<GetInfoResponse | undefined>()
const [mint, setMint] = useState<Mint>()
const [isLocalInfoVisible, setIsLocalInfoVisible] = useState<boolean>(false)
const [error, setError] = useState<AppError | undefined>()
const [info, setInfo] = useState('')
useEffect(() => {
const getInfo = async () => {
try {
if (!route.params || !route.params.mintUrl) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mintUrl')
}
log.trace('useEffect', { mintUrl: route.params.mintUrl })
setIsLoading(true)
const mint = mintsStore.findByUrl(route.params.mintUrl)
if (mint) {
const info: _GetInfoResponse = await MintClient.getMintInfo(mint.mintUrl)
mint.setStatus(MintStatus.ONLINE)
if(info.name && info.name !== mint.shortname) {
await mint.setShortname()
}
setMintInfo(info as GetInfoResponse)
setMint(mint)
} else {
throw new AppError(Err.VALIDATION_ERROR, 'Could not find mint', { mintUrl: route.params.mintUrl })
}
setIsLoading(false)
} catch (e: any) {
if (route.params.mintUrl) {
const mint = mintsStore.findByUrl(route.params.mintUrl)
if (mint) {
mint.setStatus(MintStatus.OFFLINE)
}
}
handleError(e)
}
}
getInfo()
}, [])
const toggleLocalInfo = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsLocalInfoVisible(!isLocalInfoVisible)
}
const handleError = function (e: AppError): void {
setIsLoading(false)
setError(e)
}
/** memoized mint limit info */
const mintLimitInfo = useMemo(() => {
if (typeof mintInfo === 'undefined') return;
return getMintLimits(mintInfo)
}, [mintInfo])
const colorScheme = useColorScheme()
const textDim = useThemeColor('textDim')
return (
<Screen style={$screen} preset="scroll">
<AvatarHeader
encircle={false}
fallbackIconComponent={
<SvgXml
width={60}
height={60}
xml={MintIcon}
fill='white'
/>
}
headerHeightModifier={0.26}
heading={mintInfo?.name ?? translate('mintInfo.loadingNamePlaceholder')}
text={route.params.mintUrl}>
{mint?.units ? (
<View style={{flexDirection: 'row'}}>
{mint.units.map(unit => (
<CurrencySign
textStyle={{fontSize: 16}}
containerStyle={{paddingLeft: 0, marginRight: spacing.small}}
key={unit}
mintUnit={unit}
/>
))}
</View>
) : (
<Text
style={{fontStyle: 'italic'}}
tx="mintInfo.loadingUnitsPlaceholder"
/>
)}
</AvatarHeader>
<View style={$contentContainer}>
{mintInfo?.motd && <MOTDCard info={mintInfo} />}
{mintInfo && mintLimitInfo?.any && <MintLimitsCard info={mintInfo} limitInfo={mintLimitInfo}/>}
{mintInfo && <>
<DescriptionCard info={mintInfo} />
<ContactCard info={mintInfo} popupMessage={setInfo} />
</>}
<Card
headingTx={mintInfo && "mintInfo.keyValueInfoCardHeading"}
HeadingTextProps={{style: [$sizeStyles.sm, {color: textDim}]}}
ContentComponent={
<>
{mintInfo && <MintInfoDetails info={mintInfo} popupMessage={setInfo} />}
{isLoading && (
<Loading
style={{backgroundColor: 'transparent'}}
statusMessage={translate('loadingPublicInfo')}
/>
)}
</>
}
/>
{mintInfo && <NutsCard info={mintInfo} />}
<Card
ContentComponent={
<>
<ListItem
tx="onDeviceInfo"
RightComponent={
<View style={$rightContainer}>
<Button
onPress={toggleLocalInfo}
text={isLocalInfoVisible ? translate("common.hide") : translate("common.show")}
preset="secondary"
/>
</View>
}
/>
{isLocalInfoVisible && (
<JSONTree
hideRoot
data={getSnapshot(
mintsStore.findByUrl(route.params?.mintUrl) as Mint,
) as any}
theme={{
scheme: 'default',
base00: '#eee',
}}
invertTheme={colorScheme === 'light' ? false : true}
/>
)}
</>
}
/>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
</View>
</Screen>
)
})
const $screen: ViewStyle = { flex: 1, }
const $nutIcon: ViewStyle = { paddingHorizontal: 0 }
const $limitItem: ViewStyle = {
alignItems: 'center',
flexDirection: 'row',
width: '50%',
gap: spacing.extraSmall,
}
const $limitItemWrapper: ViewStyle = {
flexDirection: 'row',
gap: spacing.extraSmall,
}
const $listItem: ViewStyle = {
columnGap: spacing.micro,
alignItems: 'center',
}
const $contentContainer: TextStyle = {
rowGap: spacing.small,
flex: 1,
padding: spacing.extraSmall,
// alignItems: 'center',
}
const $rightContainer: ViewStyle = {
padding: spacing.extraSmall,
alignSelf: 'center',
marginLeft: spacing.small,
}
const $nutItem: ViewStyle = {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
gap: spacing.extraSmall
}
const $contactListItem: ViewStyle = {
flexDirection: 'row',
columnGap: spacing.tiny
}