Files
minibits_wallet/src/screens/SendScreen.tsx
T

2221 lines
80 KiB
TypeScript

import {observer} from 'mobx-react-lite'
import React, {
useEffect,
useState,
useCallback,
useRef,
} from 'react'
import {StackActions, StaticScreenProps, useFocusEffect, useNavigation} from '@react-navigation/native'
import {
TextInput,
TextStyle,
View,
ViewStyle,
LayoutAnimation,
ScrollView,
FlatList,
ImageStyle,
Pressable,
} from 'react-native'
import {spacing, typography, useThemeColor, colors} from '../theme'
import {
Button,
Icon,
Card,
Screen,
Loading,
InfoModal,
ErrorModal,
BottomModal,
Text,
AmountInput,
} from '../components'
import {TransactionStatus, Transaction, TransactionData} from '../models/Transaction'
import {useStores} from '../models'
import {NostrClient, SYNC_STATE_WITH_MINT_TASK, SyncStateTaskResult, TransactionTaskResult, WalletTask} from '../services'
import {log} from '../services/logService'
import AppError, {Err} from '../utils/AppError'
import {translate} from '../i18n'
import {MintBalance} from '../models/Mint'
import EventEmitter from '../utils/eventEmitter'
import {ResultModalInfo} from './Wallet/ResultModalInfo'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import { Proof } from '../models/Proof'
import { Contact, ContactType } from '../models/Contact'
import { getImageSource, infoMessage } from '../utils/utils'
import { verticalScale } from '@gocodingnow/rn-size-matters'
import { MintUnit, MintUnits, formatCurrency, getCurrency } from "../services/wallet/currency"
import { MintHeader } from './Mints/MintHeader'
import { MintBalanceSelector } from './Mints/MintBalanceSelector'
import { round, toNumber } from '../utils/number'
import { QRCodeBlock } from './Wallet/QRCode'
import numbro from 'numbro'
import { TranItem } from './TranDetailScreen'
import { MemoInputCard } from '../components/MemoInputCard'
import { PaymentRequest as CashuPaymentRequest, PaymentRequestTransport, PaymentRequestTransportType, decodePaymentRequest, getDecodedToken } from '@cashu/cashu-ts'
import { ProfilePointer } from 'nostr-tools/nip19'
import { MINIBITS_NIP05_DOMAIN } from '@env'
import FastImage from 'react-native-fast-image'
import { CurrencyAmount } from './Wallet/CurrencyAmount'
import { CashuUtils } from '../services/cashu/cashuUtils'
import Clipboard from '@react-native-clipboard/clipboard'
import { getUnixTime } from 'date-fns'
import { MinibitsClient } from '../services/minibitsService'
export enum SendOption {
SEND_TOKEN = 'SEND_TOKEN',
SHOW_TOKEN = 'SHOW_TOKEN',
PAY_CASHU_PAYMENT_REQUEST = 'PAY_CASHU_PAYMENT_REQUEST',
}
type Props = StaticScreenProps<{
unit: MintUnit,
paymentOption?: SendOption,
encodedCashuPaymentRequest?: string,
draftTransactionId?: number,
contact?: Contact,
mintUrl?: string,
scannedPubkey?: string
}>
export const SendScreen = observer(function SendScreen({ route }: Props) {
const navigation = useNavigation()
const isInternetReachable = useIsInternetReachable()
const {
proofsStore,
walletProfileStore,
transactionsStore,
mintsStore,
relaysStore,
walletStore,
contactsStore,
} = useStores()
const amountInputRef = useRef<TextInput>(null)
const memoInputRef = useRef<TextInput>(null)
const pubkeyInputRef = useRef<TextInput>(null) // Initialize pubkeyInputRef
const unitRef = useRef<MintUnit>('sat')
const draftTransactionIdRef = useRef<number>(null)
const isOnlineRef = useRef<boolean>(false)
const [paymentOption, setPaymentOption] = useState<SendOption>(SendOption.SHOW_TOKEN)
const [encodedTokenToSend, setEncodedTokenToSend] = useState<string | undefined>()
const [encodedCashuPaymentRequest, setEncodedCashuPaymentRequest] = useState<string | undefined>()
const [decodedCashuPaymentRequest, setDecodedCashuPaymentRequest] = useState<CashuPaymentRequest | undefined>()
const [amountToSend, setAmountToSend] = useState<string>('0')
const [contactToSendFrom, setContactToSendFrom] = useState<Contact| undefined>()
const [contactToSendTo, setContactToSendTo] = useState<Contact| undefined>()
const [relaysToShareTo, setRelaysToShareTo] = useState<string[]>([])
const [postEndpointUrl, setPostEndpointUrl] = useState<string | undefined>()
const [memo, setMemo] = useState('')
const [availableMintBalances, setAvailableMintBalances] = useState<MintBalance[]>([])
const [mintBalanceToSendFrom, setMintBalanceToSendFrom] = useState<MintBalance | undefined>()
const [selectedProofs, setSelectedProofs] = useState<Proof[]>([])
const [transactionStatus, setTransactionStatus] = useState<TransactionStatus | undefined>()
const [transaction, setTransaction] = useState<Transaction | undefined>()
const [transactionId, setTransactionId] = useState<number | undefined>()
const [info, setInfo] = useState('')
const [error, setError] = useState<AppError | undefined>()
const [resultModalInfo, setResultModalInfo] = useState<{status: TransactionStatus, title?: string, message: string} | undefined>()
const [isLoading, setIsLoading] = useState(false)
const [isMintSelectorVisible, setIsMintSelectorVisible] = useState(false)
const [isOnline, setIsOnline] = useState(false)
const [isCashuPrWithAmount, setIsCashuPrWithAmount] = useState(false)
const [isCashuPrWithDesc, setIsCashuPrWithDesc] = useState(false)
const [isNostrDMModalVisible, setIsNostrDMModalVisible] = useState(false)
const [isProofSelectorModalVisible, setIsProofSelectorModalVisible] = useState(false) // offline mode
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
const [isNostrDMSending, setIsNostrDMSending] = useState(false)
const [isNostrDMSuccess, setIsNostrDMSuccess] = useState(false)
const [isPostModalVisible, setIsPostModalVisible] = useState(false)
const [isPostSending, setIsPostSending] = useState(false)
const [isPostSuccess, setIsPostSuccess] = useState(false)
const [isPubkeySelectorModalVisible, setIsPubkeySelectorModalVisible] = useState(false)
const [lockedPubkey, setLockedPubkey] = useState<string | undefined>() // Added lockedPubkey state
const [lockTime, setLockTime] = useState<number | undefined>(1)
useEffect(() => {
const focus = () => {
amountInputRef && amountInputRef.current
? amountInputRef.current.focus()
: false
}
const timer = setTimeout(() => focus(), 400)
return () => {
clearTimeout(timer)
}
}, [])
useEffect(() => {
const setUnitAndMint = () => {
try {
const {unit, mintUrl} = route.params
if(!unit) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint unit in route params')
}
unitRef.current = unit
if(mintUrl) {
const mintBalance = proofsStore.getMintBalance(mintUrl)
setMintBalanceToSendFrom(mintBalance)
log.trace('[setUnitAndMint] mintBalanceToSendFrom', mintBalance)
}
} catch (e: any) {
handleError(e)
}
}
setUnitAndMint()
return () => {}
}, [])
const getContactFrom = () => {
const {
pubkey,
npub,
name,
picture,
} = walletProfileStore
return {
pubkey,
npub,
name,
picture
} as Contact
}
// Send to contact
useFocusEffect(
useCallback(() => {
const {paymentOption, contact} = route.params
const prepareSendToContact = () => {
try {
let relays: string[] = []
log.trace('[prepareSendToContact] selected contact', contact, paymentOption)
if(contact?.type === ContactType.PUBLIC) {
relays = relaysStore.allPublicUrls
} else {
relays = relaysStore.allUrls
}
if (relays.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing NOSTR relays')
}
setPaymentOption(SendOption.SEND_TOKEN)
setContactToSendFrom(getContactFrom())
setContactToSendTo(contact)
setRelaysToShareTo(relays)
if(encodedTokenToSend) {
toggleNostrDMModal() // open if we already have a token
}
//reset
//@ts-ignore
navigation.setParams({
paymentOption: undefined,
contact: undefined
})
} catch(e: any) {
handleError(e)
}
}
const handlePaymentRequest = async () => {
try {
/*const cashuPaymentRequest = new CashuPaymentRequest(
[
{
type: PaymentRequestTransportType.POST,
target: 'https://shuestand.mountainlake.io/api/v1/withdrawals/wd_62b13c9d-1082-4b2d-809e-1e6723f74c52/nut18'
}
],
'test1234',
10,
'sat',
['https://m7.mountainlake.io'],
'memo'
)
const encoded = cashuPaymentRequest.toEncodedRequest()
log.trace('[handlePaymentRequest] TEST payment request', encoded)*/
setPaymentOption(SendOption.PAY_CASHU_PAYMENT_REQUEST)
setContactToSendFrom(getContactFrom())
const {encodedCashuPaymentRequest, draftTransactionId} = route.params
if(draftTransactionId) {
draftTransactionIdRef.current = draftTransactionId
}
if (!encodedCashuPaymentRequest) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing encodedCashuPaymentRequest.')
}
const pr: CashuPaymentRequest = decodePaymentRequest(encodedCashuPaymentRequest)
log.trace('[handlePaymentRequest] decoded payment request', pr)
setDecodedCashuPaymentRequest(pr)
setEncodedCashuPaymentRequest(encodedCashuPaymentRequest)
if(!pr.transport || pr.transport.length === 0) {
throw new AppError(Err.VALIDATION_ERROR,
'Payment request can not be paid as it does not have any transport defined.',
{caller: 'handlePaymentRequest', paymentRequest: pr}
)
}
const transports: PaymentRequestTransport[] = pr.transport
// Check for NOSTR transport first
const nostrTransport = transports.find(t => t.type === PaymentRequestTransportType.NOSTR)
if (nostrTransport) {
const decoded = NostrClient.decodeNprofile(nostrTransport.target)
const pubkey = (decoded.data as ProfilePointer).pubkey
const npub = NostrClient.getNpubkey(pubkey)
let relays = (decoded.data as ProfilePointer).relays?.slice(0, 5)
if(!relays || relays.length === 0) {
relays = NostrClient.getAllRelays()
}
let contactTo = {
pubkey,
npub,
} as Contact
const existing = contactsStore.findByPubkey(pubkey)
if(!existing) {
try {
const profile = await NostrClient.getProfileFromRelays(pubkey, relays)
if(profile) {
contactTo.nip05 = profile.nip05
contactTo.picture = profile.picture
contactTo.lud16 = profile.lud16
contactTo.name = profile.name
contactTo.isExternalDomain = profile.nip05.includes(MINIBITS_NIP05_DOMAIN) ? false : true
contactsStore.addContact(contactTo)
}
} catch (e:any) {
log.warn('[handlePaymentRequest] Could not get the payee profile from relays.')
}
} else {
contactTo = existing
}
setContactToSendTo(contactTo)
setRelaysToShareTo(relays)
} else {
// Fallback to POST transport
const postTransport = transports.find(t => t.type === PaymentRequestTransportType.POST)
if (postTransport) {
setPostEndpointUrl(postTransport.target)
} else {
// Error if neither transport is supported
throw new AppError(Err.VALIDATION_ERROR, 'Payment request only supports NOSTR or POST transports, but neither is available.')
}
}
log.trace('[handlePaymentRequest]', {pr})
if(pr.unit && !MintUnits.includes(pr.unit as MintUnit)) {
throw new AppError(Err.VALIDATION_ERROR, `Wallet does not support ${pr.unit} unit.`)
}
if (pr.unit) {
unitRef.current = pr.unit as MintUnit
}
if (pr.description && pr.description.length > 0) {
setMemo(pr.description)
setIsCashuPrWithDesc(true)
}
if (pr.amount) {
setAmountToSend(`${numbro(pr.amount / getCurrency(unitRef.current).precision)
.format({
thousandSeparated: true,
mantissa: getCurrency(unitRef.current).mantissa
})}`)
setIsCashuPrWithAmount(true)
}
const availableBalances: MintBalance[] = []
if (pr.mints && pr.mints.length > 0) {
for (const mint of pr.mints) {
if (mintsStore.mintExists(mint)) {
const mintBalance = proofsStore.getMintBalance(mint)
availableBalances.push(mintBalance!)
}
}
if (availableBalances.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, 'None of the mints accepted by this payment request are in your wallet.', {mints: pr.mints})
}
const withEnoughBalance = availableBalances.filter(balance => {
const unitBalance = balance.balances[unitRef.current]
if(!pr.amount) {
return balance
}
if(pr.amount && pr.amount > 0 && unitBalance && unitBalance >= pr.amount) {
return balance
}
return null
})
if (pr.amount && withEnoughBalance.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, `Not enough balance to pay this payment request. Required: ${pr.amount} ${unitRef.current}.`)
}
setAvailableMintBalances(withEnoughBalance)
setMintBalanceToSendFrom(withEnoughBalance[0])
log.trace('[handlePaymentRequest] available mint balances for this payment request', {availableBalances, withEnoughBalance})
} else {
let withEnoughBalance: MintBalance[] = []
if(pr.amount && pr.amount > 0) {
withEnoughBalance = proofsStore.getMintBalancesWithEnoughBalance(pr.amount, unitRef.current)
setAvailableMintBalances(withEnoughBalance)
} else {
withEnoughBalance = proofsStore.getMintBalancesWithUnit(unitRef.current)
setAvailableMintBalances(withEnoughBalance)
}
setMintBalanceToSendFrom(withEnoughBalance[0])
}
setIsMintSelectorVisible(true)
//reset
//@ts-ignore
navigation.setParams({
paymentOption: undefined,
encodedCashuPaymentRequest: undefined
})
} catch(e: any) {
handleError(e)
}
}
if(paymentOption && contact && paymentOption === SendOption.SEND_TOKEN) {
prepareSendToContact()
}
if(paymentOption && paymentOption === SendOption.PAY_CASHU_PAYMENT_REQUEST) {
handlePaymentRequest()
}
}, [route.params?.paymentOption])
)
// Scan pubkey to lock to
useEffect(() => {
const {scannedPubkey} = route.params
log.trace('[useEffect]', scannedPubkey)
const handleScannedPubkey = () => {
setLockedPubkey(scannedPubkey)
setIsPubkeySelectorModalVisible(true)
}
if(scannedPubkey) {
handleScannedPubkey()
}
}, [route.params?.scannedPubkey])
useEffect(() => {
if (isInternetReachable !== null) {
// Update ref immediately (for use in handlers/async)
isOnlineRef.current = isInternetReachable
// Only update state if it's a real change → triggers re-render
if (isInternetReachable !== isOnline) {
log.trace('[isOnline] status change', {isInternetReachable})
setIsOnline(isInternetReachable)
if(isInternetReachable) {
setInfo('') // clear any offline-specific messages
} else {
const availableBalances = proofsStore.getMintBalancesWithEnoughBalance(1, unitRef.current)
if (availableBalances.length === 0) {
setInfo('Not enough funds to send')
return // keep isOfflineSend false or as-is
}
setAvailableMintBalances(availableBalances)
}
}
}
}, [isInternetReachable])
// ====================== 2. Sync State Result Listener ======================
const handleSyncStateResult = useCallback(
async (result: SyncStateTaskResult) => {
log.trace('[SendScreen] handleSyncStateResult triggered', { result, transactionId })
if (!transactionId) return;
const { completedTransactionIds, errorTransactionIds, transactionStateUpdates } = result
if (completedTransactionIds?.includes(transactionId)) {
log.trace('[SendScreen] Ecash claimed successfully', { transactionId })
const amountSentInt = Math.round(
toNumber(amountToSend || '0') * getCurrency(unitRef.current).precision
);
const currency = getCurrency(unitRef.current)
setIsNostrDMModalVisible(false)
setResultModalInfo({
status: TransactionStatus.COMPLETED,
title: '🚀 That was fast!',
message: `${formatCurrency(amountSentInt, currency.code)} ${currency.code} were received by the payee.`,
});
// Delay showing result modal to allow NostrDM modal to fully close
setTimeout(() => {
setIsProofSelectorModalVisible(false)
setTransactionStatus(TransactionStatus.COMPLETED)
setIsResultModalVisible(true)
}, 500)
return
}
if (errorTransactionIds?.includes(transactionId)) {
log.trace('[SendScreen] Sync error detected', { transactionId });
const update = transactionStateUpdates?.find(u => u.tId === transactionId);
const message = update?.message || 'Transaction failed to complete on the mint.';
setIsNostrDMModalVisible(false);
setIsProofSelectorModalVisible(false);
setResultModalInfo({
status: TransactionStatus.ERROR,
title: 'Send failed',
message,
});
setTransactionStatus(TransactionStatus.ERROR);
// Delay showing result modal to allow NostrDM modal to fully close
setTimeout(() => {
setIsResultModalVisible(true);
}, 300);
}
},
[
transactionId,
amountToSend,
],
);
const syncStateListenerRef = useRef<((r: SyncStateTaskResult) => void) | null>(null);
useEffect(() => {
// Clean up early if no transactionId
if (!transactionId) {
if (syncStateListenerRef.current) {
EventEmitter.off(`ev_${SYNC_STATE_WITH_MINT_TASK}_result`, syncStateListenerRef.current);
syncStateListenerRef.current = null;
}
return;
}
const eventName = `ev_${SYNC_STATE_WITH_MINT_TASK}_result`;
// Remove any previous listener (in case deps changed)
if (syncStateListenerRef.current) {
EventEmitter.off(eventName, syncStateListenerRef.current);
}
const handler = (result: SyncStateTaskResult) => {
handleSyncStateResult(result);
// "keep listening" for poller if our transaction is still pending
const shouldStaySubscribed = (() => {
const {transactionStateUpdates} = result
if(transactionStateUpdates.length === 0) {
return true
} else {
return false
}
})();
// Only unsubscribe if the condition is NOT met
if (!shouldStaySubscribed) {
EventEmitter.off(eventName, handler);
syncStateListenerRef.current = null;
}
// If shouldStaySubscribed === true → we keep the handler attached
};
syncStateListenerRef.current = handler;
EventEmitter.on(eventName, handler);
// Cleanup on unmount or when transactionId/handleSyncStateResult changes
return () => {
if (syncStateListenerRef.current) {
EventEmitter.off(eventName, syncStateListenerRef.current);
syncStateListenerRef.current = null;
}
};
}, [transactionId, handleSyncStateResult]);
const toggleNostrDMModal = () => setIsNostrDMModalVisible(previousState => !previousState)
const toggleProofSelectorModal = () => setIsProofSelectorModalVisible(previousState => !previousState)
const toggleResultModal = () => {
log.warn('[toggleResultModal] start', isResultModalVisible)
setIsResultModalVisible(previousState => !previousState)
}
const togglePubkeySelectorModal = () => setIsPubkeySelectorModalVisible(previousState => !previousState)
const togglePostModal = () => setIsPostModalVisible(previousState => !previousState)
const validateAndProcessAmount = function (amountString: string, unit: MintUnit) {
// Normalize empty string to "0"
const normalizedAmount = amountString.trim() === "" ? "0" : amountString.trim()
const precision = getCurrency(unit).precision
const amount = round(toNumber(normalizedAmount) * precision, 0)
const isValid = typeof amount === "number" && !Number.isNaN(amount) && amount > 0
return {
amount: isValid ? amount : 0,
amountString: normalizedAmount
}
}
const onAmountEndEditing = function () {
try {
const { amount, amountString } = validateAndProcessAmount(amountToSend, unitRef.current)
log.trace('[onAmountEndEditing]', amount, amountString)
if (amount && amount > 0) {
const availableBalances = proofsStore.getMintBalancesWithEnoughBalance(amount, unitRef.current)
if (availableBalances.length === 0) {
log.trace('[onAmountEndEditing] payCommon_insufficientFunds')
infoMessage(translate('payCommon_insufficientFunds'))
return
}
setAvailableMintBalances(availableBalances)
// Default mint if not set from route params is the one with the highest balance
if (!mintBalanceToSendFrom) {
setMintBalanceToSendFrom(availableBalances[0])
}
LayoutAnimation.easeInEaseOut()
setIsMintSelectorVisible(true)
} else {
infoMessage(translate('payCommon_amountZeroOrNegative'))
return
}
} catch (e: any) {
handleError(e)
}
}
const onMemoEndEditing = function () {
LayoutAnimation.easeInEaseOut()
// Show mint selector
if (availableMintBalances.length > 0) {
setIsMintSelectorVisible(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 onMintBalanceSelect = function (balance: MintBalance) {
setMintBalanceToSendFrom(balance)
}
const onLockPubkeyStart = function () {
togglePubkeySelectorModal()
}
const onLockPubkeySelect = function () {
if (!lockedPubkey || lockedPubkey.length === 0) {
onLockPubkeyCancel()
return
}
if (lockedPubkey.startsWith('nsec')) {
throw new AppError(Err.VALIDATION_ERROR, 'Invalid key. Please provide public key in NPUB or HEX format.')
}
const contact = contactsStore.findByNpub(lockedPubkey) || contactsStore.findByPubkey(lockedPubkey)
if (contact) {
log.trace('[onLockPubkeySelect] Provided pubkey belongs to a contact', { contactName: contact.name })
let relays: string[] = []
if (contact?.type === ContactType.PUBLIC) {
relays = relaysStore.allPublicUrls
} else {
relays = relaysStore.allUrls
}
if (relays.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing NOSTR relays')
}
setPaymentOption(SendOption.SEND_TOKEN)
setContactToSendFrom(getContactFrom())
setContactToSendTo(contact)
setRelaysToShareTo(relays)
}
togglePubkeySelectorModal()
}
const onLockPubkeyCancel = function () {
togglePubkeySelectorModal()
setLockedPubkey(undefined)
setContactToSendTo(undefined)
}
const onMintBalanceConfirm = async function () {
try {
if(!mintBalanceToSendFrom) {
throw new AppError(Err.VALIDATION_ERROR, 'Please select mint balance to send from.')
}
const { amount: amountToSendInt } = validateAndProcessAmount(amountToSend, unitRef.current)
const exactMatchProofs: Proof[] = []
if(!isOnlineRef.current) {
const availableProofs = proofsStore.getByMint(mintBalanceToSendFrom.mintUrl, { isPending: false, unit: unitRef.current });
const autoSelectedProofs = CashuUtils.getProofsToSend(amountToSendInt, availableProofs)
const autoSelectedAmount = CashuUtils.getProofsAmount(autoSelectedProofs)
const isExactMatch = autoSelectedAmount === amountToSendInt
log.trace("[onMintBalanceConfirm]", {isOnline: isOnlineRef.current, amountToSendInt, autoSelectedAmount, isExactMatch})
// setSelectedProofs(autoSelectedProofs) //
if(!isExactMatch) {
// TODO need to improve algo auto-selected proofs
/* setAmountToSend(numbro(selectedAmount / getCurrency(unitRef.current).precision).format({
thousandSeparated: true,
mantissa: getCurrency(unitRef.current).mantissa
})) */
resetSelectedProofs()
// show proof selector modal
setIsProofSelectorModalVisible(true)
return
} else {
exactMatchProofs.push(...autoSelectedProofs)
}
}
setIsLoading(true)
//@ts-ignore
const p2pk: {
pubkey: string;
locktime?: number;
refundKeys?: Array<string>
} = {}
log.trace('[onMintBalanceConfirm] lockedPubkey', { lockedPubkey })
if (lockedPubkey && lockedPubkey.length > 0) {
if (lockedPubkey.startsWith('npub')) {
p2pk.pubkey = '02' + NostrClient.getHexkey(lockedPubkey)
} else {
if (lockedPubkey.length === 64) {
p2pk.pubkey = '02' + lockedPubkey
} else if (lockedPubkey.length === 66) {
p2pk.pubkey = lockedPubkey
} else {
throw new AppError(Err.VALIDATION_ERROR, 'Invalid key. Please provide public key in NPUB or HEX format.')
}
}
if (lockTime && lockTime > 0) {
p2pk.locktime = getUnixTime(new Date(Date.now() + lockTime * 24 * 60 * 60))
log.trace('[onMintBalanceConfirm] Locktime', { pubkey: p2pk.pubkey, locktime: p2pk.locktime })
}
}
const result = await WalletTask.sendQueueAwaitable(
mintBalanceToSendFrom as MintBalance,
amountToSendInt,
unitRef.current,
memo,
exactMatchProofs.length > 0 ? exactMatchProofs : selectedProofs, // autoSelected proofs are not yet in state
p2pk,
draftTransactionIdRef.current || undefined
)
await handleSendTaskResult(result)
} catch (e: any) {
handleError(e)
}
}
const handleSendTaskResult = async (result: TransactionTaskResult) => {
log.trace('[SendScreen] handleSendTaskResult start')
setIsLoading(false)
const { transaction, error, encodedTokenToSend } = result
// Update transaction state
if (transaction) {
setTransactionStatus(transaction.status)
setTransaction(transaction)
setTransactionId(transaction.id)
// Link transaction to Cashu payment request + contacts (only for Cashu PR flow)
if (
paymentOption === SendOption.PAY_CASHU_PAYMENT_REQUEST &&
decodedCashuPaymentRequest?.id &&
encodedCashuPaymentRequest
) {
transaction.update({
paymentId: decodedCashuPaymentRequest.id,
paymentRequest: encodedCashuPaymentRequest,
profile: contactToSendFrom ? JSON.stringify(contactToSendFrom) : undefined,
sentTo: contactToSendTo
? contactToSendTo.nip05 || contactToSendTo.name || null
: null,
sentFrom: contactToSendFrom
? contactToSendFrom.nip05 || contactToSendFrom.name || null
: null,
})
}
}
// Save token if present (e.g. for manual copy/share)
if (encodedTokenToSend) {
setEncodedTokenToSend(encodedTokenToSend);
}
// ——— Error Handling ———
if (error || !transaction) {
const message = error?.params?.message || error?.message || 'Unknown error';
setResultModalInfo({
status: (transaction?.status || TransactionStatus.ERROR) as TransactionStatus,
title: error?.params?.message ? error.message : 'Send failed',
message,
})
setIsResultModalVisible(true)
return;
}
// ——— Success Path ———
setIsMintSelectorVisible(false);
if (paymentOption === SendOption.SEND_TOKEN) {
toggleNostrDMModal()
}
if (paymentOption === SendOption.PAY_CASHU_PAYMENT_REQUEST) {
if (relaysToShareTo.length > 0) {
toggleNostrDMModal()
} else if (postEndpointUrl) {
togglePostModal()
}
}
}
const increaseProofsCounterAndRetry = async function () {
try {
const walletInstance = await walletStore.getWallet(
mintBalanceToSendFrom?.mintUrl as string,
unitRef.current,
{withSeed: true}
)
const mintInstance = mintsStore.findByUrl(mintBalanceToSendFrom?.mintUrl as string)
const counter = mintInstance!.getProofsCounterByKeysetId!(walletInstance.keysetId)
counter!.increaseProofsCounter(10)
// retry send
onMintBalanceConfirm()
} catch (e: any) {
handleError(e)
} finally {
toggleResultModal() //close
}
}
const retryAfterSpentCleaned = async function () {
try {
// retry send
onMintBalanceConfirm()
} catch (e: any) {
handleError(e)
} finally {
toggleResultModal() //close
}
}
const onMintBalanceCancel = async function () {
resetState()
gotoWallet()
}
const sendAsNostrDM = async function () {
try {
if(!contactToSendFrom || !contactToSendTo) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing sender or receiver information.')
}
if(!encodedTokenToSend) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing token to send.')
}
if(!mintBalanceToSendFrom) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint balance context.')
}
setIsNostrDMSending(true)
let messageContent: string | undefined = undefined
if(paymentOption === SendOption.SEND_TOKEN) {
const message = `nostr:${contactToSendFrom.npub} sent you ${amountToSend} ${getCurrency(unitRef.current).code} from Minibits wallet!`
messageContent = message + ' \n' + encodedTokenToSend
}
if(paymentOption === SendOption.PAY_CASHU_PAYMENT_REQUEST) {
if(!decodedCashuPaymentRequest) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing payment request to pay.')
}
// keysetsV2 support
const mintKeysetIds = mintsStore.findByUrl(mintBalanceToSendFrom.mintUrl)?.keysetIds
if(!mintKeysetIds || mintKeysetIds.length === 0) {
throw new AppError(Err.NOTFOUND_ERROR, 'Missing keysetIds in the wallet state', {
mintUrl: mintBalanceToSendFrom.mintUrl
})
}
const decodedTokenToSend = getDecodedToken(encodedTokenToSend, mintKeysetIds)
messageContent = JSON.stringify({
id: decodedCashuPaymentRequest.id,
mint: decodedTokenToSend.mint,
unit: decodedTokenToSend.unit,
proofs: decodedTokenToSend.proofs,
})
}
const keys = await walletStore.getCachedWalletKeys()
const sentEvent = await NostrClient.encryptAndSendDirectMessageNip17(
contactToSendTo.pubkey,
messageContent!,
relaysToShareTo,
keys.NOSTR,
walletProfileStore.nip05
)
setIsNostrDMSending(false)
if(sentEvent) {
setIsNostrDMSuccess(true)
if(!transactionId) {
return
}
const transaction = transactionsStore.findById(transactionId)
if(!transaction || !transaction.data) {
return
}
let updated = [] as unknown as TransactionData
try {
updated = JSON.parse(transaction.data)
} catch (e) {}
if(updated.length > 2) {
updated[2].sentToRelays = relaysToShareTo
updated[2].sentEvent = sentEvent
// status does not change, just add event and relay info to tx.data
transaction.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(updated)
})
}
if(contactToSendTo) {
transaction.update({
profile: JSON.stringify(contactToSendTo),
sentTo: contactToSendTo.nip05handle ?? contactToSendTo.name!
})
}
} else {
setInfo('Nostr relays could not confirm that the message has been sent')
}
} catch (e: any) {
handleError(e)
}
}
const sendAsPostRequest = async function () {
try {
if (!postEndpointUrl) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing POST endpoint URL.')
}
if (!encodedTokenToSend) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing token to send.')
}
if (!decodedCashuPaymentRequest) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing payment request to pay.')
}
if (!mintBalanceToSendFrom) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint balance context.')
}
setIsPostSending(true)
// keysetsV2 support
const mintKeysetIds = mintsStore.findByUrl(mintBalanceToSendFrom.mintUrl)?.keysetIds
if(!mintKeysetIds || mintKeysetIds.length === 0) {
throw new AppError(Err.NOTFOUND_ERROR, 'Missing keysetIds in the wallet state', {
mintUrl: mintBalanceToSendFrom.mintUrl
})
}
const decodedTokenToSend = getDecodedToken(encodedTokenToSend, mintKeysetIds)
const payload = {
id: decodedCashuPaymentRequest.id,
mint: decodedTokenToSend.mint,
unit: decodedTokenToSend.unit,
proofs: decodedTokenToSend.proofs,
memo: decodedTokenToSend.memo || undefined,
}
await MinibitsClient.fetchApi(postEndpointUrl, {
method: 'POST',
body: payload,
jwtAuthRequired: false
})
setIsPostSending(false)
setIsPostSuccess(true)
if (!transactionId) {
return
}
const transaction = transactionsStore.findById(transactionId)
if (!transaction || !transaction.data) {
return
}
let updated = [] as unknown as TransactionData
try {
updated = JSON.parse(transaction.data)
} catch (e) {}
if (updated.length > 2) {
updated[2].postEndpointUrl = postEndpointUrl
transaction.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(updated)
})
}
} catch (e: any) {
handleError(e)
}
}
const toggleSelectedProof = function (proof: Proof) {
const precision = getCurrency(unitRef.current).precision
setSelectedProofs(prevSelectedProofs => {
const isSelected = prevSelectedProofs.some(
p => p.secret === proof.secret
)
// validate amountToSend s.t. it does not crash numbro
const _amountToSend = (!amountToSend || !amountToSend.trim() || Number.isNaN(parseInt(amountToSend)))
? 0
: parseInt(amountToSend);
if (isSelected) {
// If the proof is already selected, remove it from the array
setAmountToSend(`${numbro(_amountToSend - proof.amount / precision).format({
thousandSeparated: true,
mantissa: getCurrency(unitRef.current).mantissa
})}`)
return prevSelectedProofs.filter(p => p.secret !== proof.secret)
} else {
// If the proof is not selected, add it to the array
setAmountToSend(`${numbro(_amountToSend + proof.amount / precision).format({
thousandSeparated: true,
mantissa: getCurrency(unitRef.current).mantissa
})}`)
return [...prevSelectedProofs, proof]
}
})
}
const resetSelectedProofs = function () {
setSelectedProofs([])
setAmountToSend('0')
}
const onOfflineSendConfirm = function () {
// Update amountToSend to match exactly the selected proofs amount before proceeding
// const selectedAmount = CashuUtils.getProofsAmount(selectedProofs)
// const precision = getCurrency(unitRef.current).precision
// const formattedAmount = numbro(selectedAmount / precision)
// .format({
// thousandSeparated: true,
// mantissa: getCurrency(unitRef.current).mantissa
// })
// setAmountToSend(formattedAmount)
toggleProofSelectorModal() // close
// Pass the exact selected amount directly to onMintBalanceConfirm
onMintBalanceConfirm()
}
const gotoContacts = function () {
if(encodedTokenToSend && contactToSendTo) {
toggleNostrDMModal() // open if we already have a token
} else {
//@ts-ignore
navigation.navigate('ContactsNavigator', {
screen: 'Contacts',
params: {paymentOption: SendOption.SEND_TOKEN}
})
}
}
const gotoWallet = function() {
resetState()
navigation.dispatch(
StackActions.popToTop()
)
}
const resetState = function () {
// reset state so it does not interfere next payment
setAmountToSend('')
setMemo('')
setIsMintSelectorVisible(false)
setIsNostrDMModalVisible(false)
setIsNostrDMSending(false)
setIsNostrDMModalVisible(false)
setIsProofSelectorModalVisible(false)
setIsLoading(false)
setResultModalInfo(undefined)
setIsResultModalVisible(false)
setLockTime(undefined)
setLockedPubkey(undefined)
setPostEndpointUrl(undefined)
setIsPostModalVisible(false)
setIsPostSending(false)
setIsPostSuccess(false)
}
const handleError = function(e: AppError): void {
// TODO resetState() on all tx data on error? Or save txId to state and allow retry / recovery?
setIsNostrDMSending(false)
setIsProofSelectorModalVisible(false)
setIsNostrDMModalVisible(false)
setIsLoading(false)
setIsPostSending(false)
setIsPostModalVisible(false)
setError(e)
}
const headerBg = useThemeColor('header')
const amountInputColor = useThemeColor('amountInput')
const hintColor = useThemeColor('textDim')
const inputText = useThemeColor('text')
const inputBg = useThemeColor('background')
const buttonBorder = useThemeColor('card')
const onPasteLockedPubkey = async function () {
try {
const pastedText = await Clipboard.getString()
setLockedPubkey(pastedText)
} catch (e: any) {
handleError(e)
}
}
const gotoScan = async function () {
log.trace('[onScanLockedPubkey]')
togglePubkeySelectorModal()
//@ts-ignore
navigation.navigate('Scan', {
unit: unitRef.current,
mintUrl: mintBalanceToSendFrom?.mintUrl
})
}
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<MintHeader
mint={mintBalanceToSendFrom ? mintsStore.findByUrl(mintBalanceToSendFrom?.mintUrl) : undefined}
unit={unitRef.current}
/>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<View style={$amountContainer}>
<AmountInput
ref={amountInputRef}
value={amountToSend}
onChangeText={amount => setAmountToSend(amount)}
unit={unitRef.current}
onEndEditing={transactionStatus !== TransactionStatus.PENDING ? onAmountEndEditing : undefined}
selectTextOnFocus={true}
editable={(transactionStatus === TransactionStatus.PENDING || isCashuPrWithAmount)
? false
: true
}
/>
</View>
{lockedPubkey ? (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
//marginTop: isConvertedAmountVisible() ? -spacing.extraSmall : undefined
}}
>
<Icon
icon="faLock"
size={spacing.small}
color={amountInputColor}
/>
<Text
size='xs'
tx="sendLocked"
style={{color: amountInputColor, marginLeft: spacing.tiny}}
/>
</View>
) : (
<Text
size='xs'
tx='amountSend'
style={{
color: amountInputColor,
textAlign: 'center',
//marginTop: spacing.extraSmall
}}
/>
)}
</View>
<View style={$contentContainer}>
{!encodedTokenToSend && (
<MemoInputCard
memo={memo}
ref={memoInputRef}
setMemo={setMemo}
disabled={transactionStatus === TransactionStatus.PENDING || isCashuPrWithDesc}
onMemoDone={onMemoDone}
onMemoEndEditing={onMemoEndEditing}
/>
)}
{isMintSelectorVisible && !encodedTokenToSend && (
<MintBalanceSelector
mintBalances={availableMintBalances}
selectedMintBalance={mintBalanceToSendFrom as MintBalance}
unit={unitRef.current}
title={translate("sendScreen_sendFromMintBalanceSel")}
confirmTitle={isOnline
? translate("sendScreen_createTokenBtn")
: translate("sendScreen_sendOfflineBtn")
}
secondaryConfirmTitle='Lock'
onMintBalanceSelect={onMintBalanceSelect}
onSecondaryMintBalanceSelect={onLockPubkeyStart}
onCancel={onMintBalanceCancel}
onMintBalanceConfirm={onMintBalanceConfirm}
/>
)}
{transactionStatus === TransactionStatus.PENDING && encodedTokenToSend && paymentOption && (
<>
<QRCodeBlock
qrCodeData={encodedTokenToSend as string}
title='Ecash token to send'
type='EncodedV4Token'
/>
<TokenOptionsBlock
toggleNostrDMModal={toggleNostrDMModal}
contactToSendTo={contactToSendTo}
gotoContacts={gotoContacts}
/>
</>
)}
{transaction && transactionStatus === TransactionStatus.COMPLETED && (
<Card
style={{padding: spacing.medium}}
ContentComponent={
<>
<TranItem
label="tranDetailScreen_sentTo"
isFirst={true}
value={mintsStore.findByUrl(transaction.mint)?.shortname as string}
/>
{transaction?.memo && (
<TranItem
label="receiverMemo"
value={transaction.memo as string}
/>
)}
<TranItem
label="transactionCommon_feePaid"
value={transaction.fee || 0}
unit={unitRef.current}
isCurrency={true}
/>
<TranItem
label="tranDetailScreen_status"
value={transaction.status as string}
/>
</>
}
/>
)}
{(transactionStatus === TransactionStatus.COMPLETED) && (
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={gotoWallet}
/>
</View>
</View>
)}
</View>
<BottomModal
isVisible={isProofSelectorModalVisible}
ContentComponent={
<SelectProofsBlock
mintBalanceToSendFrom={mintBalanceToSendFrom as MintBalance}
unit={unitRef.current}
selectedProofs={selectedProofs}
showNoExactMatchMessage={true}
toggleProofSelectorModal={toggleProofSelectorModal}
toggleSelectedProof={toggleSelectedProof}
resetSelectedProofs={resetSelectedProofs}
onOfflineSendConfirm={onOfflineSendConfirm}
/>
}
onBackButtonPress={toggleProofSelectorModal}
onBackdropPress={toggleProofSelectorModal}
/>
<BottomModal
isVisible={isPubkeySelectorModalVisible}
ContentComponent={
<View style={$bottomModal}>
<Text tx="sendLockEcash" preset="subheading" />
<Text
size="xxs"
style={{color: hintColor}}
tx="sendLockEcashDesc"
/>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.small,
}}>
<TextInput
ref={pubkeyInputRef}
onChangeText={pubkey =>
setLockedPubkey(pubkey)
}
value={lockedPubkey}
autoCapitalize="none"
keyboardType="default"
maxLength={66}
selectTextOnFocus={true}
style={[
$pubkeyInput,
{backgroundColor: inputBg, color: inputText},
]}
/>
<Button
preset='secondary'
tx='commonPaste'
style={{
borderRadius: 0,
marginLeft: -spacing.small,
borderLeftWidth: 1,
borderLeftColor: buttonBorder
}}
onPress={onPasteLockedPubkey}
/>
<Button
preset='secondary'
tx="commonScan"
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
marginHorizontal: 1,
}}
onPress={gotoScan}
/>
</View>
{contactsStore.contacts.length > 0 && (
<View
style={{
flexDirection: 'row',
marginTop: spacing.medium,
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: inputBg
}}
>
<FlatList
data={contactsStore.contacts}
renderItem={({ item }) => {
return (
<ContactItem
contact={item}
onPress={() => setLockedPubkey(item.npub)}
containerStyle={{
paddingHorizontal: spacing.small,
borderRadius: spacing.tiny,
backgroundColor: lockedPubkey === item.npub ? inputBg : undefined,
}}
/>
)
}}
horizontal={true}
keyExtractor={(item) => item.npub}
style={{marginBottom: spacing.medium}}
contentContainerStyle={{
justifyContent: 'center', // Center items horizontally
alignItems: 'center', // Center items vertically
flexGrow: 1,
}}
/>
</View>
)}
<Text
size="xxs"
style={{color: hintColor, marginTop: spacing.small}}
tx="sendLockFor"
/>
<View
style={[
$buttonContainer,
{
marginVertical: spacing.small,
borderBottomWidth: 1,
borderBottomColor: inputBg,
paddingBottom: spacing.small,
alignSelf: 'stretch',
justifyContent: 'center'
}
]}
>
<Button
preset={lockTime === 1 ? "secondary" : "tertiary"}
text={"1 day"}
onPress={() => setLockTime(1)}
style={{
minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny),
marginRight: spacing.small
}}
textStyle={{fontSize: 14}}
/>
<Button
preset={lockTime === 7 ? "secondary" : "tertiary"}
text={"1 week"}
onPress={() => setLockTime(7)}
style={{
minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny),
marginRight: spacing.small
}}
textStyle={{fontSize: 14}}
/>
<Button
preset={lockTime ? "tertiary" : "secondary"}
text={"forever"}
onPress={() => setLockTime(undefined)}
style={{
minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny),
marginRight: spacing.small
}}
textStyle={{fontSize: 14}}
/>
</View>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
<Button
tx="sendLock"
LeftAccessory={() => (<Icon icon="faLock" color="white" size={spacing.medium}/>)}
onPress={onLockPubkeySelect}
style={{marginRight: spacing.medium}}
/>
<Button
style={{marginRight: spacing.medium}}
preset="tertiary"
tx="commonCancel"
onPress={onLockPubkeyCancel}
/>
</View>
</View>
}
onBackButtonPress={togglePubkeySelectorModal}
onBackdropPress={togglePubkeySelectorModal}
/>
<BottomModal
isVisible={isNostrDMModalVisible ? true : false}
ContentComponent={
(isNostrDMSuccess ? (
<NostrDMSuccessBlock
toggleNostrDMModal={toggleNostrDMModal}
contactToSendFrom={contactToSendFrom as Contact}
contactToSendTo={contactToSendTo as Contact}
amountToSend={amountToSend}
onClose={gotoWallet}
/>
) : (
<SendAsNostrDMBlock
toggleNostrDMModal={toggleNostrDMModal}
encodedTokenToSend={encodedTokenToSend as string}
contactToSendFrom={contactToSendFrom as Contact}
contactToSendTo={contactToSendTo as Contact}
relaysToShareTo={relaysToShareTo}
amountToSend={amountToSend}
unit={unitRef.current}
sendAsNostrDM={sendAsNostrDM}
isNostrDMSending={isNostrDMSending}
/>
))
}
onBackButtonPress={toggleNostrDMModal}
onBackdropPress={toggleNostrDMModal}
/>
<BottomModal
isVisible={isPostModalVisible}
ContentComponent={
isPostSuccess ? (
<PostSuccessBlock
togglePostModal={togglePostModal}
onClose={gotoWallet}
/>
) : (
<SendAsPostRequestBlock
togglePostModal={togglePostModal}
encodedTokenToSend={encodedTokenToSend as string}
postEndpointUrl={postEndpointUrl as string}
amountToSend={amountToSend}
unit={unitRef.current}
sendAsPostRequest={sendAsPostRequest}
isPostSending={isPostSending}
/>
)
}
onBackButtonPress={togglePostModal}
onBackdropPress={togglePostModal}
/>
<BottomModal
isVisible={isResultModalVisible ? true : false}
ContentComponent={
<>
{resultModalInfo &&
transactionStatus === TransactionStatus.COMPLETED && (
<>
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title={resultModalInfo?.title || "Success!"}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={gotoWallet}
/>
</View>
</>
)}
{resultModalInfo &&
transactionStatus === TransactionStatus.ERROR && (
<>
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.angry500}
title={resultModalInfo?.title || "Send failed"}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
{((/already.*signed|duplicate key/i.test(resultModalInfo.message))) ? (
<Button
preset="secondary"
text={"Retry again"}
onPress={increaseProofsCounterAndRetry}
/>
) : resultModalInfo.message.toLowerCase().includes('token already spent') || resultModalInfo.message.toLowerCase().includes('some spent ecash') ? (
<Button
preset="secondary"
text={"Retry again"}
onPress={retryAfterSpentCleaned}
/>
) : (
<Button
preset="secondary"
tx={'commonClose'}
onPress={toggleResultModal}
/>
)}
</View>
</>
)}
</>
}
onBackButtonPress={toggleResultModal}
onBackdropPress={toggleResultModal}
/>
{isLoading && <Loading />}
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
</Screen>
)
}
)
/**
* allows you to manually select the ecash banknotes for offline sending
*/
const SelectProofsBlock = observer(function (props: {
mintBalanceToSendFrom: MintBalance
unit: MintUnit
selectedProofs: Proof[]
showNoExactMatchMessage?: boolean
toggleProofSelectorModal: any
toggleSelectedProof: any
resetSelectedProofs: any
onOfflineSendConfirm: any
}) {
const { proofsStore } = useStores()
const hintColor = useThemeColor('textDim')
const statusColor = useThemeColor('header')
const onCancel = function () {
props.resetSelectedProofs()
props.toggleProofSelectorModal()
}
const $informStyle: TextStyle = {
paddingHorizontal: spacing.small,
textAlign: 'center',
marginTop: spacing.extraSmall,
fontWeight: '500'
}
return (
<View style={$bottomModal}>
<View
style={[
{
alignSelf: 'center',
marginTop: spacing.tiny,
paddingHorizontal: spacing.tiny,
borderRadius: spacing.tiny,
backgroundColor: colors.palette.primary200,
},
]}>
<Text
tx="sendScreen_offlinemode"
style={[
{
color: statusColor,
fontSize: 10,
fontFamily: typography.primary?.light,
padding: 0,
lineHeight: 16,
}
]}
/>
</View>
<Text tx='sendCreateToken' style={{ marginTop: spacing.large }} />
{props.showNoExactMatchMessage && (<Text
tx="sendOfflineApproxMatch"
style={$informStyle}
size='xs'
/>)}
<Text
tx='sendOfflineExactDenoms'
style={{ color: hintColor, paddingHorizontal: spacing.tiny, marginTop: spacing.extraSmall, textAlign: 'center' }}
size='xs'
/>
<CurrencyAmount
amount={CashuUtils.getProofsAmount(props.selectedProofs)}
mintUnit={props.unit}
size='extraLarge'
containerStyle={{ marginTop: spacing.large, marginBottom: spacing.small, alignItems: 'center' }}
/>
<View style={{
maxHeight: spacing.screenHeight * 0.45,
borderWidth: 1,
borderColor: hintColor,
borderRadius: spacing.medium,
marginTop: spacing.small
}}>
<FlatList<Proof>
data={proofsStore.getByMint(props.mintBalanceToSendFrom.mintUrl, { isPending: false, unit: props.unit })}
renderItem={({ item }) => {
const isSelected = props.selectedProofs.some(
p => p.secret === item.secret
)
return (
<Button
preset={isSelected ? 'default' : 'secondary'}
onPress={() => props.toggleSelectedProof(item)}
text={`${item.amount}`}
style={{ minWidth: 80, margin: spacing.small }}
/>
)
}}
numColumns={3}
keyExtractor={(item) => item.secret}
/>
</View>
<Text tx="sendOffline_usageHint" style={{ color: hintColor, marginVertical: spacing.extraSmall, textAlign: 'center' }} size="xs" />
<View style={[$bottomContainer, { marginTop: spacing.extraLarge }]}>
<View style={[$buttonContainer]}>
<Button
tx="sendCreateToken"
onPress={props.onOfflineSendConfirm}
style={{ marginRight: spacing.medium }}
/>
<Button
preset="secondary"
tx="commonCancel"
onPress={onCancel}
/>
</View>
</View>
</View>
)
})
const TokenOptionsBlock = observer(function (props: {
toggleNostrDMModal: any
contactToSendTo?: Contact
gotoContacts: any
}) {
return (
<View style={{flex: 1}}>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
{props.contactToSendTo ? (
<Button
text={`Send to ${props.contactToSendTo.nip05}`}
preset='secondary'
onPress={props.toggleNostrDMModal}
style={{
minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny),
}}
LeftAccessory={() => (
<Icon
icon='faPaperPlane'
// color="white"
size={spacing.medium}
/>
)}
/>
) : (
<Button
tx='sendToContact'
preset='secondary'
onPress={props.gotoContacts}
style={{
minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny),
}}
LeftAccessory={() => (
<Icon
icon='faPaperPlane'
// color="white"
size={spacing.medium}
/>
)}
/>
)}
</View>
</View>
</View>
)
})
const SendAsNostrDMBlock = observer(function (props: {
toggleNostrDMModal: any
encodedTokenToSend: string
contactToSendFrom: Contact
contactToSendTo: Contact
relaysToShareTo: string[]
amountToSend: string
unit: MintUnit
sendAsNostrDM: any
isNostrDMSending: boolean
}) {
const sendBg = useThemeColor('background')
const tokenTextColor = useThemeColor('textDim')
return (
<View style={$bottomModal}>
<Text text={'Send to contact'} />
<NostDMInfoBlock
contactToSendFrom={props.contactToSendFrom}
amountToSend={props.amountToSend}
unit={props.unit}
contactToSendTo={props.contactToSendTo}
/>
<ScrollView
style={[
$tokenContainer,
{backgroundColor: sendBg, marginHorizontal: spacing.small},
]}>
<Text
selectable
text={props.encodedTokenToSend}
style={{color: tokenTextColor, paddingBottom: spacing.medium, fontFamily: typography.code?.normal}}
size="xxs"
/>
</ScrollView>
{props.isNostrDMSending ? (
<View style={[$buttonContainer, {minHeight: verticalScale(55)}]}>
<Loading />
</View>
) : (
<View style={$buttonContainer}>
<Button
tx="commonSend"
onPress={props.sendAsNostrDM}
style={{marginRight: spacing.medium}}
LeftAccessory={() => (
<Icon
icon="faPaperPlane"
color="white"
size={spacing.medium}
//containerStyle={{marginRight: spacing.small}}
/>
)}
/>
<Button
preset="tertiary"
tx="commonClose"
onPress={props.toggleNostrDMModal}
/>
</View>
)}
</View>
)
})
const NostrDMSuccessBlock = observer(function (props: {
toggleNostrDMModal: any
contactToSendFrom: Contact
contactToSendTo: Contact
amountToSend: string
onClose: any
}) {
return (
<View style={$bottomModal}>
{/*<NostDMInfoBlock
contactToSendFrom={props.contactToSendFrom}
amountToSend={props.amountToSend}
contactToSendTo={props.contactToSendTo}
/>*/}
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title="Success!"
message="Ecash has been successfully sent."
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={props.onClose}
/>
</View>
</View>
)
})
const SendAsPostRequestBlock = observer(function (props: {
togglePostModal: any
encodedTokenToSend: string
postEndpointUrl: string
amountToSend: string
unit: MintUnit
sendAsPostRequest: any
isPostSending: boolean
}) {
const sendBg = useThemeColor('background')
const tokenTextColor = useThemeColor('textDim')
return (
<View style={$bottomModal}>
<Text text={'Send payment request'} />
<Text
text={props.postEndpointUrl}
style={{color: tokenTextColor, textAlign: 'center', marginVertical: spacing.small}}
size="xs"
/>
<CurrencyAmount
amount={round(toNumber(props.amountToSend) * getCurrency(props.unit).precision, 0)}
mintUnit={props.unit}
size='large'
containerStyle={{alignItems: 'center', marginBottom: spacing.small}}
/>
<ScrollView
style={[
$tokenContainer,
{backgroundColor: sendBg, marginHorizontal: spacing.small},
]}>
<Text
selectable
text={props.encodedTokenToSend}
style={{color: tokenTextColor, paddingBottom: spacing.medium, fontFamily: typography.code?.normal}}
size="xxs"
/>
</ScrollView>
{props.isPostSending ? (
<View style={[$buttonContainer, {minHeight: verticalScale(55)}]}>
<Loading />
</View>
) : (
<View style={$buttonContainer}>
<Button
tx="commonSend"
onPress={props.sendAsPostRequest}
style={{marginRight: spacing.medium}}
LeftAccessory={() => (
<Icon
icon="faPaperPlane"
color="white"
size={spacing.medium}
/>
)}
/>
<Button
preset="tertiary"
tx="commonClose"
onPress={props.togglePostModal}
/>
</View>
)}
</View>
)
})
const PostSuccessBlock = observer(function (props: {
togglePostModal: any
onClose: any
}) {
return (
<View style={$bottomModal}>
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title="Success!"
message="Payment successfully sent"
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={props.onClose}
/>
</View>
</View>
)
})
const ContactItem = function (props: {
contact: Contact
onPress: any
containerStyle?: ViewStyle
}) {
const textColor = useThemeColor('textDim')
const tokenTextColor = useThemeColor('textDim')
return (
<Pressable
style={[{flexDirection: 'column', alignItems: 'center'}, props.containerStyle]}
onPress={props.onPress}
>
{props.contact && props.contact.picture ? (
<View style={{borderRadius: 20, overflow: 'hidden'}}>
<FastImage style={[
$profileIcon, {
width: 40,
height: props.contact.isExternalDomain ? 40 : 43,
borderRadius: props.contact.isExternalDomain ? 20 : 0,
}] as import("react-native-fast-image").ImageStyle}
source={{
uri: getImageSource(props.contact.picture as string)
}}
/>
</View>
) : (
<Icon
icon='faCircleUser'
size={38}
color={tokenTextColor}
/>
)}
<Text
size='xxs'
ellipsizeMode='tail'
numberOfLines={1}
style={{color: tokenTextColor, maxWidth: 50}}
text={props.contact.name|| props.contact.npub}
/>
</Pressable>
)
}
const NostDMInfoBlock = observer(function (props: {
contactToSendFrom: Contact
amountToSend: string
unit: MintUnit
contactToSendTo: Contact
}) {
const tokenTextColor = useThemeColor('textDim')
const amountToSendInt = round(toNumber(props.amountToSend) * getCurrency(props.unit).precision, 0)
const amountToSendDisplay = formatCurrency(amountToSendInt, getCurrency(props.unit).code)
return(
<View style={{flexDirection: 'row', justifyContent: 'space-around', alignItems: 'center', marginBottom: spacing.medium}}>
<ContactItem
contact={props.contactToSendFrom}
onPress={undefined}
containerStyle={{height: 60}}
/>
<Text size='xxs' style={{color: tokenTextColor, textAlign: 'center', marginLeft: 30, marginBottom: 20}} text='...........' />
<View style={{flexDirection: 'column', alignItems: 'center'}}>
<Icon
icon='faPaperPlane'
size={spacing.medium}
color={tokenTextColor}
/>
<Text size='xxs' style={{color: tokenTextColor, marginBottom: -10}} text={`${amountToSendDisplay} ${getCurrency(props.unit).code}`} />
</View>
<Text size='xxs' style={{color: tokenTextColor, textAlign: 'center', marginRight: 30, marginBottom: 20}} text='...........' />
<ContactItem
contact={props.contactToSendTo}
onPress={undefined}
containerStyle={{height: 60}}
/>
</View>
)
})
const $screen: ViewStyle = {
flex: 1,
}
const $headerContainer: TextStyle = {
alignItems: 'center',
padding: spacing.extraSmall,
paddingTop: 0,
height: spacing.screenHeight * 0.20,
}
const $pubkeyInput: TextStyle = {
flex: 1,
// borderRadius: 0,
borderRadius: spacing.extraSmall,
fontSize: verticalScale(16),
padding: spacing.small,
alignSelf: 'stretch',
textAlignVertical: 'top',
// borderWidth: 1,
}
const $amountContainer: ViewStyle = {
//height: spacing.screenHeight * 0.11,
}
const $contentContainer: TextStyle = {
flex: 1,
padding: spacing.extraSmall,
marginTop: -spacing.extraLarge * 1.5
}
const $tokenContainer: ViewStyle = {
borderRadius: spacing.small,
alignSelf: 'stretch',
padding: spacing.small,
maxHeight: 114,
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 = {
alignItems: 'center',
paddingVertical: spacing.large,
}
const $qrCodeContainer: ViewStyle = {
backgroundColor: 'white',
paddingHorizontal: spacing.small,
marginHorizontal: spacing.small,
marginBottom: spacing.small,
borderRadius: spacing.small,
alignItems: 'center',
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
}
const $profileIcon: ImageStyle = {
padding: spacing.medium,
}
const $bottomContainer: ViewStyle = {
// position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flex: 1,
justifyContent: 'flex-end',
marginBottom: spacing.tiny,
alignSelf: 'stretch',
// opacity: 0,
}