Files
minibits_wallet/src/screens/MintInfoScreen.tsx
T
2026-01-10 00:39:30 +01:00

637 lines
21 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, { useEffect, useMemo, useState } from 'react'
import { GetInfoResponse as CashuGetInfoResponse, SwapMethod } from '@cashu/cashu-ts'
import { CashuUtils } from '../services/cashu/cashuUtils'
import Clipboard from '@react-native-clipboard/clipboard'
import { observer } from 'mobx-react-lite'
import { getSnapshot } from 'mobx-state-tree'
import { DimensionValue, Image, LayoutAnimation, Platform, ScrollView, TextStyle, UIManager, View, ViewStyle } from 'react-native'
import JSONTree from 'react-native-json-tree'
import {
$sizeStyles,
Button,
Card,
ErrorModal,
Header,
Icon,
IconTypes,
InfoModal,
ListItem,
Loading,
MintIcon,
Screen,
Text,
} from '../components'
import { CollapsibleText } from '../components/CollapsibleText'
import { translate } from '../i18n'
import { useStores } from '../models'
import { Mint, MintStatus } from '../models/Mint'
import { 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'
import { CurrencyCode, formatCurrency } from '../services/wallet/currency'
import { QRShareModal } from '../components/QRShareModal'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
import FastImage from 'react-native-fast-image'
interface DetailedNutInfo {
methods: Array<SwapMethod>;
disabled: boolean;
}
interface MethodLimit {
min?: number,
max?: number,
}
type GetInfoResponse = CashuGetInfoResponse & {icon_url: string}
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'
}
type Props = StaticScreenProps<{
mintUrl : string
}>
export const MintInfoScreen = observer(function MintInfoScreen({ route }: Props) {
const navigation = useNavigation()
useHeader({
leftIcon: 'faArrowLeft',
onLeftPress: () => {
navigation.goBack()
},
})
const { mintsStore, walletStore } = useStores()
//const { walletStore } = nonPersistedStores
const [isLoading, setIsLoading] = useState(false)
const [mintInfo, setMintInfo] = useState<GetInfoResponse | undefined>()
const [mint, setMint] = useState<Mint>()
const [isLocalInfoVisible, setIsLocalInfoVisible] = useState<boolean>(false)
const [isShareModalVisible, setIsShareModalVisible] = useState(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) {
setMint(mint)
const info: GetInfoResponse = await walletStore.getMintInfo(mint.mintUrl)
mint.setStatus(MintStatus.ONLINE)
if(info.name && info.name !== mint.shortname) {
await mint.setShortname()
}
setMintInfo(info as GetInfoResponse)
} 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.easeInEaseOut()
setIsLocalInfoVisible(!isLocalInfoVisible)
}
const toggleShareModal = () => setIsShareModalVisible(previousState => !previousState)
const gotoShare = function () {
toggleShareModal()
}
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')
const headerBg = useThemeColor('header')
const iconColor = useThemeColor('textDim')
const headerTitle = useThemeColor('headerTitle')
return (
<Screen contentContainerStyle={$screen} preset="fixed">
<View style={[$headerContainer, {backgroundColor: headerBg, justifyContent: 'space-around', paddingBottom: spacing.huge}]}>
{mintInfo && mintInfo.icon_url ? (
<FastImage
style={
{
width: spacing.extraLarge,
height: spacing.extraLarge,
borderRadius: spacing.small,
}
}
source={{uri: mintInfo.icon_url}}
/>
):(
<View
style={{
marginEnd: spacing.small,
flex: 0,
borderRadius: spacing.small,
padding: spacing.extraSmall,
backgroundColor: colors.palette.orange600
}}
>
<SvgXml
width={spacing.medium}
height={spacing.medium}
xml={MintIcon}
fill='white'
/>
</View>
)}
<Text preset='subheading' text={mintInfo?.name ?? mint?.shortname} style={{color: headerTitle}}/>
{mint?.units && (
<View style={{flexDirection: 'row'}}>
{mint.units.map(unit => (
<CurrencySign
//containerStyle={{marginTop: spacing.small}}
key={unit}
mintUnit={unit}
textStyle={{color: 'white'}}
/>
))}
</View>
)}
</View>
<ScrollView style={$contentContainer}>
{isLoading ? (
<View style={{height: spacing.screenHeight * 0.4}}><Loading/></View>
) : (
<>
{mintInfo?.motd && mintInfo?.motd !== 'Message to users' && <MOTDCard info={mintInfo} />}
{mintInfo && <DescriptionCard info={mintInfo} />}
<Card
labelTx={"mintInfo_mintUrlHeading"}
HeadingTextProps={{style: [$sizeStyles.sm, {color: textDim}]}}
content={mint?.mintUrl}
RightComponent={
<View style={$rightContainer}>
<Button
onPress={gotoShare}
preset='secondary'
LeftAccessory={() => <Icon icon='faQrcode' color={iconColor} />}
/>
</View>
}
/>
{mintInfo && mintLimitInfo?.any && <MintLimitsCard info={mintInfo} limitInfo={mintLimitInfo}/>}
{mintInfo &&
<>
<ContactCard info={mintInfo} popupMessage={setInfo} />
<Card
labelTx={"mintInfo_keyValueInfoCardHeading"}
ContentComponent={
<>
{mintInfo && <MintInfoDetails info={mintInfo} popupMessage={setInfo} />}
</>
}
/>
</>
}
{mintInfo && <NutsCard info={mintInfo} />}
<Card
ContentComponent={
<>
<ListItem
tx="onDeviceInfo"
RightComponent={
<View style={$rightContainer}>
<Button
onPress={toggleLocalInfo}
text={isLocalInfoVisible ? translate("commonHide") : translate("commonShow")}
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}
/>
)}
</>
}
/>
</>
)}
<QRShareModal
data={route.params.mintUrl}
shareModalTx='mintsScreen_share'
subHeading={mintInfo?.name ?? translate('mintInfo_loadingNamePlaceholder')}
type='URL'
isVisible={isShareModalVisible}
onClose={toggleShareModal}
/>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
</ScrollView>
</Screen>
)
})
function MOTDCard(props: {info: GetInfoResponse}) {
const textDim = useThemeColor('textDim')
return (
<Card
RightComponent={
<View style={{justifyContent: 'center'}}>
<Icon icon="faCircleExclamation" color={'white'} size={20} />
</View>
}
// heading='Important'
ContentComponent={
<Text style={{fontStyle: 'italic'}} text={props.info.motd} />
}
style={{backgroundColor: colors.dark.warn}}
/>
)
}
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) => {
const min = `${formatCurrency(m.min as number, CurrencyCode.SAT)}`
const max = `${formatCurrency(m.max as number, CurrencyCode.SAT)}`
return (typeof m.min !== 'undefined' && typeof m.max !== 'undefined' ? `${min} – ${max}`
: typeof m.min !== 'undefined' && typeof m.max === 'undefined' ? `min: ${min}`
: typeof m.max !== 'undefined' && typeof m.min === 'undefined' ? `max: ${max}`
: void 0)
}
const LimitItem = (props: { m: MethodLimit, type: 'mint' | 'melt' }) => (
<View style={$limitItem}>
<Icon
icon={props.type === 'mint' ? 'faCircleArrowDown' : 'faCircleArrowUp'}
color={colors.palette.success200}
size={16}
containerStyle={$nutIcon}
/>
<Text text={limitText(props.m)} />
</View>)
const textDim = useThemeColor('textDim')
return (<Card
labelTx="mintInfo_mintMeltHeading"
// HeadingTextProps={{ style: [$sizeStyles.sm, { color: textDim }] }}
ContentComponent={<>
<View style={$limitItemWrapper}>
<Text tx="mintInfo_depositMint" style={{ width: '50%' }} />
<Text tx="mintInfo_withdrawMelt" style={{ width: '50%' }} />
</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
// labelTx="mintInfo_descriptionHeading"
//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' && Array.isArray(info) && info.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[0].mpp ?? false])
continue;
}
// see https://github.com/cashubtc/nutshell/issues/588
if (nut === '17' && Array.isArray(info) && info.length > 0) {
nutsSimple.push(['17', info.some(m => m.unit === 'sat')])
continue;
}
// proper nut-17 response handling (not implemented in nutshell yet)
if (nut === '17' && 'supported' in info && Array.isArray(info.supported) && info.supported.length > 0) {
nutsSimple.push(['17', info.supported.some(m => m.unit === 'sat')])
continue;
}
if (nut === '19' && 'cached_endpoints' in info && Array.isArray(info.cached_endpoints) && info.cached_endpoints.length > 0) {
nutsSimple.push(['19', info.cached_endpoints.some(e => e.method === 'POST')])
continue;
}
if ('disabled' in info && info.disabled === false) { // detailed
supportedNutsDetailed.push([nut, info])
} else if ('supported' in info && typeof info.supported !== 'undefined') { // simple
nutsSimple.push([nut, !!info.supported])
} else {
nutsSimple.push([nut, false]) // fallback or detailed but disabled nut
}
}
const smallNutCols = 4
return (
<Card
labelTx="mintInfo_nutsHeading"
style={{marginBottom: spacing.small}}
// 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')
type Contact = {method: string, info: string}
let contacts: Contact[] = []
for (const contact of props.info.contact) {
if (Array.isArray(contact) && contact.length === 2) {
contacts.push({ method: contact[0], info: contact[1] })
} else if ('method' in contact && 'info' in contact) {
contacts.push(contact)
}
}
return (
<Card
labelTx="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(({ method, info }, index) => (
<ListItem
style={$contactListItem}
key={method}
text={method}
textStyle={$sizeStyles.xs}
LeftComponent={<Icon icon={method in contactIconMap ? contactIconMap[method] : 'faAddressBook'} color={textDim}/>}
RightComponent={<View style={{ width: spacing.screenWidth * 0.6 }}><Text text={info}/></View>}
topSeparator={index !== 0}
onLongPress={() => {
Clipboard.setString(info)
props.popupMessage(translate('commonCopySuccessParam', { param: info }))
}}
/>
))}
</>
)
}
/>
)
}
/** don't render these because they're rendered in separate components */
const detailsHiddenKeys = new Set(['name', 'motd', 'description', 'description_long', 'nuts', 'contact', 'icon_url', 'time'])
/** 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 = CashuUtils.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('commonCopySuccessParam', { 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,
}
}
const $screen: ViewStyle = { }
const $nutIcon: ViewStyle = { paddingHorizontal: 0 }
const $limitItem: ViewStyle = {
alignItems: 'center',
flexDirection: 'row',
width: '50%',
gap: spacing.extraSmall,
}
const $limitItemWrapper: ViewStyle = {
flexDirection: 'row',
marginVertical: spacing.extraSmall
}
const $listItem: ViewStyle = {
columnGap: spacing.micro,
alignItems: 'center',
}
const $headerContainer: TextStyle = {
alignItems: 'center',
// padding: spacing.tiny,
height: spacing.screenHeight * 0.20,
}
const $contentContainer: TextStyle = {
marginTop: -spacing.extraLarge * 1.5,
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
}