Simplify payment confirmation screen (#1029)

* update translations

* simplifying payments display

* remove unconsistant icons

* adding translations

* fix usdb value display

* fix tokens bug
This commit is contained in:
Blake Kaufman
2026-07-25 11:34:25 -04:00
committed by GitHub
parent 9018cea29e
commit c2a0fce581
17 changed files with 954 additions and 475 deletions
+102 -21
View File
@@ -23,6 +23,8 @@ jest.mock('../app/constants', () => {
return {
...theme,
CENTER: {},
ICONS: {},
USDB_TOKEN_ID: 'usdb-token-id',
};
});
@@ -35,6 +37,22 @@ jest.mock('react-native-email-link', () => ({
openComposer: jest.fn(),
}));
// Pulled in transitively by the recipient card. Ships untranspiled ESM.
jest.mock('expo-image', () => ({ Image: () => null }));
jest.mock('react-native-country-flag', () => ({
__esModule: true,
default: () => null,
}));
// The recipient pill's labels come from `i18next.t` directly (recipientCard is
// shared with the pre-send screen, which is not a hook context).
jest.mock('i18next', () => ({
__esModule: true,
default: {
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
},
}));
jest.mock('../app/functions', () => ({
copyToClipboard: jest.fn(),
}));
@@ -49,11 +67,6 @@ jest.mock('../app/functions/lrc20/formatTokensBalance', () => ({
default: (amount, decimals) => `tokens-${amount}-${decimals}`,
}));
jest.mock('../app/functions/displayCorrectDenomination', () => ({
__esModule: true,
default: ({ amount }) => `denom-${amount}`,
}));
jest.mock('../app/functions/customUUID', () => ({
__esModule: true,
default: () => 'uuid',
@@ -78,6 +91,7 @@ jest.mock('../app/functions/lnurl', () => ({
jest.mock('../app/functions/sendBitcoin/getPhonePaymentAddress', () => ({
canonicalizePhonePaymentAddress: value => value,
getPhonePaymentDisplay: () => null,
}));
jest.mock('../app/hooks/themeColors', () => () => ({
@@ -101,19 +115,6 @@ jest.mock('../context-store/appStatus', () => ({
useAppStatus: () => ({ screenDimensions: { width: 400, height: 800 } }),
}));
jest.mock('../context-store/context', () => ({
useGlobalContextProvider: () => ({
masterInfoObject: {
fiatCurrency: 'USD',
userBalanceDenomination: 'sats',
},
}),
}));
jest.mock('../context-store/nodeContext', () => ({
useNodeContext: () => ({ fiatStats: { coin: 'USD', value: 100000000 } }),
}));
jest.mock('../context-store/globalContacts', () => ({
useGlobalContactsInfo: () => ({ decodedAddedContacts: [] }),
}));
@@ -189,13 +190,24 @@ function balanceInputProps(renderer) {
return nodes.length ? nodes[0].props.componentProps : null;
}
function renderedText(renderer) {
const RN = require('react-native');
return renderer.root
.findAllByType(RN.Text)
.flatMap(node => node.props.children)
.filter(child => typeof child === 'string');
}
function satTextProps(renderer) {
const nodes = renderer.root.findAllByProps({ testID: 'formatted-sat-text' });
return nodes.length ? nodes[0].props.componentProps : null;
}
// paymentType lives at the top level of a transaction, not in `details` — the
// recipient pill reads it from there.
const successfulOutgoingTx = {
details: { amount: 1500, direction: 'OUTGOING', paymentType: 'lightning' },
paymentType: 'lightning',
details: { amount: 1500, direction: 'OUTGOING' },
};
const fiatPaymentDisplay = {
@@ -256,7 +268,7 @@ describe('ConfirmTxPage amount rendering', () => {
expect(props.forceCurrency).toBe('EUR');
});
test('uses token metadata for an LRC20 send rendered through FormattedBalanceInput', async () => {
test('uses the send screen ticker for an LRC20 send rendered through FormattedBalanceInput', async () => {
mockSparkInformation = {
tokens: {
'token-id': { tokenMetadata: { tokenTicker: 'USDB', decimals: 6 } },
@@ -272,7 +284,12 @@ describe('ConfirmTxPage amount rendering', () => {
},
},
displayAmount: '12.34',
paymentDisplay: { denomination: 'fiat', forceCurrency: 'USD', forceFiatStats: null },
displayTokenTicker: 'USDB',
paymentDisplay: {
denomination: 'fiat',
forceCurrency: 'USD',
forceFiatStats: null,
},
});
const props = balanceInputProps(renderer);
@@ -280,4 +297,68 @@ describe('ConfirmTxPage amount rendering', () => {
expect(props.customCurrencyCode).toBe('USDB');
expect(props.maxDecimals).toBe(6);
});
test('keeps the fiat display for a USDB-funded send the send screen showed as fiat', async () => {
mockSparkInformation = {
tokens: {
'token-id': { tokenMetadata: { tokenTicker: 'USDB', decimals: 6 } },
},
};
const renderer = await renderConfirm({
transaction: {
details: {
amount: 5000,
direction: 'OUTGOING',
isLRC20Payment: true,
LRC20Token: 'token-id',
},
},
displayAmount: '12.34',
// no displayTokenTicker: the send screen labelled this one "$"
paymentDisplay: {
denomination: 'fiat',
forceCurrency: 'USD',
forceFiatStats: null,
},
});
const props = balanceInputProps(renderer);
expect(props).not.toBeNull();
expect(props.customCurrencyCode).toBeFalsy();
expect(props.forceCurrency).toBe('USD');
expect(props.maxDecimals).toBe(2);
});
});
describe('ConfirmTxPage recipient pill', () => {
beforeEach(() => {
mockSparkInformation = { tokens: {} };
});
test('names the funding asset for a bitcoin-funded lightning send', async () => {
const renderer = await renderConfirm({ transaction: successfulOutgoingTx });
expect(renderedText(renderer)).toContain(
'wallet.sendPages.sendPaymentScreen.lightningPayment',
);
});
test('names the dollar balance for a USD-funded bolt11 (recorded as spark)', async () => {
const renderer = await renderConfirm({
transaction: {
paymentType: 'spark',
details: {
amount: 1500,
direction: 'OUTGOING',
isLRC20Payment: true,
LRC20Token: 'usdb-token-id',
destinationChain: 'lightning',
},
},
});
expect(renderedText(renderer)).toContain(
'wallet.sendPages.sendPaymentScreen.dollarPayment',
);
});
});
@@ -4,6 +4,7 @@ import getPhonePaymentAddress, {
getPhonePaymentCountry,
canonicalizePhonePaymentAddress,
getPhonePostProvider,
getPhonePaymentDisplay,
fetchPhonePaymentInvoice,
PROVIDER_COUNTRY_CURRENCY,
} from '../../../app/functions/sendBitcoin/getPhonePaymentAddress';
@@ -335,3 +336,31 @@ describe('fetchPhonePaymentInvoice', () => {
await expect(fetchPhonePaymentInvoice(args)).rejects.toThrow();
});
});
describe('getPhonePaymentDisplay', () => {
it('returns iso code + international-formatted number for a KE provider address', () => {
expect(getPhonePaymentDisplay(KE)).toEqual({
isoCode: 'KE',
formatted: '+254 717 252303',
});
});
it('formats a ZM address whose local part is national (0977…)', () => {
const res = getPhonePaymentDisplay('0977123456@bitzed.xyz');
expect(res.isoCode).toBe('ZM');
expect(res.formatted.startsWith('+260')).toBe(true);
});
it('handles a POST-provider (Burundi) address', () => {
expect(getPhonePaymentDisplay('25779561234@exchanger.mysatoshis.bi').isoCode).toBe('BI');
});
it('returns null for a non-phone lightning address', () => {
expect(getPhonePaymentDisplay('satoshi@walletofsatoshi.com')).toBeNull();
});
it('returns null for non-string / malformed input', () => {
expect(getPhonePaymentDisplay(undefined)).toBeNull();
expect(getPhonePaymentDisplay('no-at-sign')).toBeNull();
});
});
+164
View File
@@ -0,0 +1,164 @@
// The recipient pill names AND illustrates the asset that LEFT the wallet, not the
// rail it took — no lightning bolts or spark logos for bitcoin/dollar sends. USDB
// rides the same LRC20 rails as any other token, so the token id is the only thing
// separating "Dollar payment" from "Token payment" — these assertions are what stops
// a dollar-funded send from reading "Bitcoin payment" or showing a bolt again.
const USDB = 'usdb-token-id';
jest.mock('expo-image', () => ({ Image: () => null }));
jest.mock('react-native-country-flag', () => ({
__esModule: true,
default: () => null,
}));
jest.mock('../app/constants', () => ({
COLORS: {},
ICONS: {},
USDB_TOKEN_ID: 'usdb-token-id',
}));
jest.mock(
'../app/components/admin/homeComponents/contacts/internalComponents/profileImage',
() => ({ __esModule: true, default: () => null }),
);
jest.mock('i18next', () => ({
__esModule: true,
default: {
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
},
}));
const {
resolveRecipientDisplay,
} = require('../app/components/admin/homeComponents/sendBitcoin/components/recipientCard');
const LIGHTNING = 'wallet.sendPages.sendPaymentScreen.lightningPayment';
const DOLLAR = 'wallet.sendPages.sendPaymentScreen.dollarPayment';
const TOKEN = 'wallet.sendPages.sendPaymentScreen.tokenPayment';
const tx = (paymentType, details = {}) => ({ paymentType, details });
describe('resolveRecipientDisplay payment asset label', () => {
it('labels a bitcoin-funded lightning send "Bitcoin payment"', () => {
const resolved = resolveRecipientDisplay({
transaction: tx('lightning', { isLRC20Payment: false }),
});
expect(resolved).toMatchObject({
kind: 'asset',
asset: 'bitcoin',
displayName: LIGHTNING,
});
});
it('labels a dollar-funded lightning send "Dollar payment"', () => {
const resolved = resolveRecipientDisplay({
transaction: tx('lightning', {
isLRC20Payment: true,
LRC20Token: USDB,
}),
});
// Asset, not rail: dollars over lightning is a dollar icon, never a bolt.
expect(resolved).toMatchObject({
kind: 'asset',
asset: 'dollar',
displayName: DOLLAR,
});
});
it('labels a dollar-funded spark send "Dollar payment"', () => {
const resolved = resolveRecipientDisplay({
transaction: tx('spark', { isLRC20Payment: true, LRC20Token: USDB }),
});
expect(resolved).toMatchObject({
kind: 'asset',
asset: 'dollar',
displayName: DOLLAR,
});
});
it('shows the dollar icon for a USD-funded bolt11 (recorded as spark)', () => {
// payments.js writes this shape: the swap leaves as USDB over spark rails,
// but the destination is a bolt11. Either way the user sent dollars.
const resolved = resolveRecipientDisplay({
transaction: tx('spark', {
isLRC20Payment: true,
LRC20Token: USDB,
destinationChain: 'lightning',
}),
});
expect(resolved).toMatchObject({
kind: 'asset',
asset: 'dollar',
displayName: DOLLAR,
});
});
it('labels a non-USDB LRC20 send "Token payment" and keeps the spark logo', () => {
const resolved = resolveRecipientDisplay({
transaction: tx('spark', {
isLRC20Payment: true,
LRC20Token: 'some-other-token-id',
}),
});
expect(resolved).toMatchObject({ kind: 'spark', displayName: TOKEN });
});
it('labels a bitcoin-funded spark send "Bitcoin payment"', () => {
const resolved = resolveRecipientDisplay({
transaction: tx('spark', { isLRC20Payment: false, LRC20Token: '' }),
});
expect(resolved).toMatchObject({
asset: 'bitcoin',
displayName: LIGHTNING,
});
});
it('labels an on-chain send "Bitcoin payment"', () => {
const resolved = resolveRecipientDisplay({
transaction: tx('bitcoin'),
});
expect(resolved).toMatchObject({
asset: 'bitcoin',
displayName: LIGHTNING,
});
});
it('shows the dollar icon for a dollar-funded on-chain send', () => {
const resolved = resolveRecipientDisplay({
transaction: tx('bitcoin', { isLRC20Payment: true, LRC20Token: USDB }),
});
expect(resolved).toMatchObject({ asset: 'dollar', displayName: DOLLAR });
});
it('names the asset + chain for an external stablecoin, not the spark type', () => {
const resolved = resolveRecipientDisplay({
// A stablecoin send is also paymentType 'spark' with LRC20Token USDB, so
// this only passes while the stablecoin check runs ahead of both.
transaction: tx('spark', { isLRC20Payment: true, LRC20Token: USDB }),
stablecoinInfo: { asset: 'USDC', label: 'Base' },
});
expect(resolved.kind).toBe('stablecoin');
expect(resolved.displayName).toBe(
'screens.inAccount.confirmTxPage.stablecoinDesc:{"asset":"USDC","chain":"Base"}',
);
});
it('prefers a named recipient over any asset label', () => {
const resolved = resolveRecipientDisplay({
contactInfo: { name: 'Satoshi' },
transaction: tx('spark', { isLRC20Payment: true, LRC20Token: USDB }),
});
expect(resolved).toMatchObject({ kind: 'contact', displayName: 'Satoshi' });
});
it('returns null when there is nothing to name', () => {
expect(resolveRecipientDisplay({})).toBeNull();
});
it('falls through a contact with no name and no image instead of an empty pill', () => {
const resolved = resolveRecipientDisplay({
contactInfo: { uuid: 'abc' },
transaction: tx('bitcoin'),
});
expect(resolved).toMatchObject({ kind: 'asset', asset: 'bitcoin' });
});
});
@@ -1,8 +1,7 @@
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useMemo } from 'react';
import { Image } from 'expo-image';
import { ThemeText } from '../../../../../functions/CustomElements';
import { CENTER, COLORS, FONT, ICONS, SIZES } from '../../../../../constants';
import { CENTER, FONT, SIZES } from '../../../../../constants';
import GetThemeColors from '../../../../../hooks/themeColors';
import formatSparkPaymentAddress from '../functions/formatSparkPaymentAddress';
import { useNavigation } from '@react-navigation/native';
@@ -10,24 +9,14 @@ import { InputTypes } from 'bitcoin-address-parser';
import ContactProfileImage from '../../contacts/internalComponents/profileImage';
import normalizeLNURLAddress from '../../../../../functions/lnurl/normalizeLNURLAddress';
import ProfileImageRow from '../../contacts/internalComponents/profileImageRow';
import ThemeIcon from '../../../../../functions/CustomElements/themeIcon';
import { HIDDEN_OPACITY } from '../../../../../constants/theme';
import { useTranslation } from 'react-i18next';
// LNURL/lightning-address domain → ICONS key for the provider brand logo.
const LNURL_PROVIDER_ICONS = {
'aqua.net': 'aqua',
'blink.sv': 'blink',
'breez.tips': 'breez',
'cake.cash': 'cake',
'coinos.io': 'coinos',
'mannabitcoin.com': 'mannabitcoin',
'cluborange.org': 'cluborange',
'strike.me': 'strike',
'tether.me': 'tether',
'walletofsatoshi.com': 'walletofsatoshi',
'zeuspay.com': 'zeuspay',
};
import {
paymentAssetLabel,
RecipientAvatar,
resolveAssetAvatar,
resolveRecipientDisplay,
} from './recipientCard';
import ThemeIcon from '../../../../../functions/CustomElements/themeIcon';
export default function InvoiceInfo({
paymentInfo,
@@ -38,13 +27,17 @@ export default function InvoiceInfo({
isSplitPayment,
splitRecipients = [],
isUsingBranta,
isDollarBalance,
isToken,
}) {
const formmateedSparkPaymentInfo = formatSparkPaymentAddress(
paymentInfo,
undefined,
true,
);
const { t } = useTranslation();
// Labels come from `i18next.t` inside recipientCard, which doesn't subscribe
// this component to language changes — the hook does.
useTranslation();
const { backgroundOffset, backgroundColor } = GetThemeColors();
const navigate = useNavigation();
const splitContacts = splitRecipients?.map(({ contact }) => contact);
@@ -60,23 +53,21 @@ export default function InvoiceInfo({
!isSplitPayment &&
fromPage !== 'contacts' &&
!isLNURLPay &&
paymentType !== 'lightning';
paymentType !== 'lightning' &&
paymentType !== 'spark';
const isClickable = !isSplitPayment && !showsFullAddress;
// LNURL: resolve the human-readable "user@host", match the host to a provider
// logo, and drop "@host" when we have a logo (the logo conveys the provider).
// LNURL: resolve the human-readable "user@host". The shared resolver picks the
// provider brand logo, a mobile-money country flag + formatted phone number, or
// the plain address, so the pre-send and post-send screens stay in sync.
const normalizedLNURL = isLNURLPay
? normalizeLNURLAddress(paymentInfo?.data?.address) ??
paymentInfo?.data?.address ??
''
: '';
const lnurlDomain = normalizedLNURL.includes('@')
? normalizedLNURL.split('@')[1]?.toLowerCase()
: '';
const providerIconKey = LNURL_PROVIDER_ICONS[lnurlDomain];
const lnurlDisplayText = providerIconKey
? normalizedLNURL.split('@')[0]
: normalizedLNURL;
const lnurlResolved = isLNURLPay
? resolveRecipientDisplay({ lnurlAddress: normalizedLNURL })
: null;
// On-chain / spark addresses: 4-char groups with alternating weight for
// easy visual validation (mirrors depositQRView).
@@ -99,63 +90,44 @@ export default function InvoiceInfo({
if (isLNURLPay) {
paymentContent = (
<View style={styles.contactRow}>
<View
style={[
styles.profileImage,
providerIconKey
? styles.providerLogoCircle
: { backgroundColor: backgroundColor },
]}
>
{providerIconKey ? (
<Image
style={styles.providerLogo}
source={ICONS[providerIconKey]}
contentFit="contain"
/>
) : (
<ContactProfileImage
updated={undefined}
uri={undefined}
darkModeType={darkModeType}
theme={theme}
/>
)}
</View>
<ThemeText
styles={styles.addressText}
CustomNumberOfLines={1}
content={lnurlDisplayText}
/>
</View>
);
} else if (paymentType === 'lightning') {
paymentContent = (
<View style={styles.contactRow}>
<View
style={[styles.profileImage, { backgroundColor: backgroundColor }]}
>
<Image
style={[
styles.lightningIcon,
{
tintColor:
theme && darkModeType ? COLORS.darkModeText : COLORS.primary,
},
]}
source={ICONS.lightningReceiveIcon}
contentFit="contain"
<View style={styles.avatarSpacing}>
<RecipientAvatar
resolved={lnurlResolved}
theme={theme}
darkModeType={darkModeType}
backgroundColor={backgroundColor}
/>
</View>
<ThemeText
styles={styles.addressText}
CustomNumberOfLines={1}
content={t('wallet.sendPages.sendPaymentScreen.lightningPayment')}
content={lnurlResolved?.displayName}
/>
</View>
);
} else if (paymentType === 'lightning' || paymentType === 'spark') {
// Same avatar the success screen uses, so the icon can't drift between the
// two screens.
paymentContent = (
<View style={styles.contactRow}>
<View style={styles.avatarSpacing}>
<RecipientAvatar
resolved={resolveAssetAvatar({ isDollarBalance, isToken })}
theme={theme}
darkModeType={darkModeType}
backgroundColor={backgroundColor}
/>
</View>
<ThemeText
styles={styles.addressText}
CustomNumberOfLines={1}
content={paymentAssetLabel({ isDollarBalance, isToken })}
/>
</View>
);
} else {
// bitcoin / spark / lrc20
// bitcoin / spark / lrc20: show the full address so the user can verify it
// against what they scanned/pasted before signing.
paymentContent = (
<ThemeText
styles={styles.segmentText}
@@ -278,17 +250,8 @@ const styles = StyleSheet.create({
overflow: 'hidden',
marginRight: 10,
},
providerLogoCircle: {
backgroundColor: COLORS.white,
borderColor: COLORS.gray,
},
providerLogo: {
width: '100%',
height: '100%',
},
lightningIcon: {
width: 24,
height: 24,
avatarSpacing: {
marginRight: 10,
},
addressText: {
includeFontPadding: false,
@@ -0,0 +1,260 @@
import { StyleSheet, View } from 'react-native';
import { Image } from 'expo-image';
import CountryFlag from 'react-native-country-flag';
import { COLORS, ICONS, USDB_TOKEN_ID } from '../../../../../constants';
import ContactProfileImage from '../../contacts/internalComponents/profileImage';
import { getPhonePaymentDisplay } from '../../../../../functions/sendBitcoin/getPhonePaymentAddress';
import i18next from 'i18next';
// LNURL/lightning-address domain → ICONS key for the provider brand logo. When a
// domain matches, the logo conveys the provider so we drop the "@host" and show
// just the username.
export const LNURL_PROVIDER_ICONS = {
'aqua.net': 'aqua',
'blink.sv': 'blink',
'breez.tips': 'breez',
'cake.cash': 'cake',
'coinos.io': 'coinos',
'mannabitcoin.com': 'mannabitcoin',
'cluborange.org': 'cluborange',
'strike.me': 'strike',
'tether.me': 'tether',
'walletofsatoshi.com': 'walletofsatoshi',
'zeuspay.com': 'zeuspay',
};
// Names the asset that left the wallet, not the rail it took — a bolt11 paid from
// the dollar balance is a "Dollar payment". Dollar wins over token because USDB is
// an LRC20 token too.
export function paymentAssetLabel({ isDollarBalance, isToken }) {
if (isDollarBalance)
return i18next.t('wallet.sendPages.sendPaymentScreen.dollarPayment');
if (isToken)
return i18next.t('wallet.sendPages.sendPaymentScreen.tokenPayment');
return i18next.t('wallet.sendPages.sendPaymentScreen.lightningPayment');
}
// The icon follows the label: the user cares what they're sending, not which rail
// carries it. Tokens are neither bitcoin nor dollars, so they keep the spark logo.
export function resolveAssetAvatar({ isDollarBalance, isToken }) {
if (isToken) return { kind: 'spark', logo: 'sparkLogoLight' };
return { kind: 'asset', asset: isDollarBalance ? 'dollar' : 'bitcoin' };
}
// Resolves a recipient into everything the avatar + name row needs. Shared by the
// pre-send invoice info and the post-send confirmation card so the two never drift.
// `lnurlAddress` must already be normalized to `user@host`. Priority: branta →
// contact → phone/mobile-money → LNURL provider → plain lightning address →
// external stablecoin → the asset label for a raw bolt11/on-chain/spark send.
// Returns null when there is nothing at all to name. `displayName` is always
// already-translated text, never a key.
export function resolveRecipientDisplay({
lnurlAddress,
contactInfo,
brantaName,
brantaLogo,
transaction,
stablecoinInfo,
}) {
if (brantaName || brantaLogo) {
return {
kind: 'branta',
displayName: brantaName || '',
imageUri: brantaLogo || undefined,
};
}
// A contact with neither a name nor an image would render an empty pill, so
// fall through to the address/payment-type branches instead.
const contactName = contactInfo?.name || contactInfo?.uniqueName || '';
if (contactInfo && (contactName || contactInfo.imageData?.localUri)) {
return {
kind: 'contact',
displayName: contactName,
imageUri: contactInfo.imageData?.localUri,
imageUpdated: contactInfo.imageData?.updated,
};
}
if (lnurlAddress) {
const phone = getPhonePaymentDisplay(lnurlAddress);
if (phone) {
return {
kind: 'phone',
displayName: phone.formatted,
isoCode: phone.isoCode,
fullAddress: lnurlAddress,
};
}
const domain = lnurlAddress.includes('@')
? lnurlAddress.split('@')[1]?.toLowerCase()
: '';
const providerIconKey = LNURL_PROVIDER_ICONS[domain];
return {
kind: providerIconKey ? 'provider' : 'lightning',
displayName: providerIconKey ? lnurlAddress.split('@')[0] : lnurlAddress,
providerIconKey,
fullAddress: lnurlAddress,
};
}
// External-chain stablecoins name their own asset + chain, so they check ahead
// of the payment-type branches — a stablecoin send is also paymentType 'spark'.
if (stablecoinInfo) {
return {
kind: 'stablecoin',
displayName: i18next.t('screens.inAccount.confirmTxPage.stablecoinDesc', {
asset: stablecoinInfo.asset,
chain: stablecoinInfo.label,
}),
logo: stablecoinInfo.label,
};
}
// Which balance funded the send. USDB rides the same LRC20 rails as any other
// token, so the token id is what separates "Dollar" from "Token".
const details = transaction?.details || {};
const isDollarBalance =
!!details.isLRC20Payment && details.LRC20Token === USDB_TOKEN_ID;
const isToken = !!details.isLRC20Payment && !isDollarBalance;
const assetLabel = paymentAssetLabel({ isDollarBalance, isToken });
// Every rail we send over resolves to the same asset card. `destinationChain`
// catches a bolt11 funded from the dollar balance, which payments.js records as
// paymentType 'spark'. Anything else has nothing to name.
const paymentType = transaction?.paymentType;
if (
paymentType === 'lightning' ||
paymentType === 'bitcoin' ||
paymentType === 'spark' ||
details.destinationChain === 'lightning'
) {
return {
...resolveAssetAvatar({ isDollarBalance, isToken }),
displayName: assetLabel,
};
}
return null;
}
// Renders the circular avatar for a resolved recipient: branta/contact profile
// image, provider brand logo, mobile-money country flag, or the standard user-icon
// fallback for plain lightning addresses.
export function RecipientAvatar({
resolved,
theme,
darkModeType,
size = 40,
backgroundColor,
}) {
const isProvider = resolved?.kind === 'provider';
const circle = [
styles.circle,
{ width: size, height: size, borderRadius: size / 2 },
isProvider ? styles.providerCircle : { backgroundColor },
];
if (resolved?.kind === 'phone') {
return (
<View style={circle}>
{/* The flag renders 1.6x wider than `size`, so scale it to fit the
circle's diameter instead of getting cropped by the border radius. */}
<CountryFlag isoCode={resolved.isoCode} size={Math.round(size / 1.6)} />
</View>
);
}
if (isProvider) {
return (
<View style={circle}>
<Image
style={styles.fill}
source={ICONS[resolved.providerIconKey]}
contentFit="contain"
/>
</View>
);
}
if (resolved?.kind === 'spark') {
return (
<View style={circle}>
<Image
style={styles.fill}
source={ICONS[resolved.logo]}
contentFit="contain"
/>
</View>
);
}
if (resolved?.kind === 'stablecoin') {
return (
<View style={circle}>
<Image
style={styles.fill}
source={ICONS[`chain_${resolved.logo?.toLowerCase()}`]}
contentFit="contain"
/>
</View>
);
}
if (resolved?.kind === 'asset') {
const isDollar = resolved.asset === 'dollar';
return (
<View
style={[
circle,
{
backgroundColor:
theme && darkModeType
? backgroundColor
: COLORS[isDollar ? 'dollarGreen' : 'bitcoinOrange'],
},
]}
>
<Image
style={styles.assetIcon}
source={ICONS[isDollar ? 'dollarIcon' : 'bitcoinIcon']}
contentFit="contain"
/>
</View>
);
}
// branta / contact / lightning fallback all go through ContactProfileImage,
// which renders the user-icon fallback when there is no uri.
return (
<View style={circle}>
<ContactProfileImage
uri={resolved?.imageUri}
updated={resolved?.imageUpdated}
darkModeType={darkModeType}
theme={theme}
/>
</View>
);
}
const styles = StyleSheet.create({
circle: {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
providerCircle: {
backgroundColor: COLORS.white,
borderColor: COLORS.gray,
},
fill: {
width: '100%',
height: '100%',
},
assetIcon: {
width: '50%',
height: '50%',
},
});
@@ -217,6 +217,11 @@ export default function SendPaymentScreen(props) {
? masterInfoObject?.defaultSpendToken || 'Bitcoin'
: 'Bitcoin';
const selectedContactInfo = contactInfo || paymentInfo?.blitzContactInfo;
// Intentionally keyed off `contactInfo` only: including blitzContactInfo here
// would swap the token selector for the BTC/USD chooser and unpin the payment
// method for @username sends. Display is handled by the InvoiceInfo props.
const useFullTokensDisplay =
enabledLRC20 &&
isSparkPayment &&
@@ -239,7 +244,14 @@ export default function SendPaymentScreen(props) {
? paymentInfo?.data?.maxSendable / 1000
: 0;
const selectedLRC20Asset = masterTokenInfo?.tokenName || defaultToken;
// Tokens only exist on Spark — a bolt11/on-chain/LNURL invoice can never be
// funded with the default spend token. Before the decode lands paymentNetwork
// is undefined, so keep the user's pick: processSparkAddress reads it to
// decide isLRC20.
const selectedLRC20Asset =
!paymentInfo?.paymentNetwork || useFullTokensDisplay
? masterTokenInfo?.tokenName || defaultToken
: 'Bitcoin';
const seletctedToken =
masterTokenInfo?.details ||
sparkInformation?.tokens?.[selectedLRC20Asset] ||
@@ -247,6 +259,12 @@ export default function SendPaymentScreen(props) {
const tokenDecimals = seletctedToken?.tokenMetadata?.decimals ?? 0;
const tokenBalance = seletctedToken?.balance ?? 0;
const isUsingLRC20 = selectedLRC20Asset?.toLowerCase() !== 'bitcoin';
// The ticker actually shown on this screen. USDB-funded sends that weren't
// picked through the token selector display as fiat, so the confirm screen
// must be told the label instead of re-deriving it from the tx's token info.
const displayTokenTicker = isUsingLRC20
? seletctedToken?.tokenMetadata?.tokenTicker
: '';
const sendingAmount = paymentInfo?.sendAmount || 0;
const canEditAmount = paymentInfo?.canEditPayment === true;
@@ -1301,9 +1319,10 @@ export default function SendPaymentScreen(props) {
paymentInfo?.type === InputTypes.LNURL_PAY
? normalizeLNURLAddress(paymentInfo?.data?.address)
: undefined,
blitzContactInfo: paymentInfo?.blitzContactInfo,
blitzContactInfo: selectedContactInfo,
paymentDisplay: primaryDisplayRef.current,
displayAmount,
displayTokenTicker,
},
},
],
@@ -1331,9 +1350,10 @@ export default function SendPaymentScreen(props) {
paymentInfo?.type === InputTypes.LNURL_PAY
? normalizeLNURLAddress(paymentInfo?.data?.address)
: undefined,
blitzContactInfo: paymentInfo?.blitzContactInfo,
blitzContactInfo: selectedContactInfo,
paymentDisplay: primaryDisplayRef.current,
displayAmount,
displayTokenTicker,
},
},
],
@@ -1373,6 +1393,8 @@ export default function SendPaymentScreen(props) {
fiatValueConvertedSendAmount,
paymentValidation,
displayAmount,
displayTokenTicker,
selectedContactInfo,
]);
const handleSelectPaymentMethod = useCallback(
@@ -1446,15 +1468,6 @@ export default function SendPaymentScreen(props) {
});
}, [paymentInfo?.verificationURL, navigate, t]);
const sendingAsset =
selectedLRC20Asset === 'Bitcoin'
? !isLightningPayment &&
!isBitcoinPayment &&
!(isSparkPayment && receiverExpectsCurrency === 'sats')
? t('constants.dollars_upper')
: t('constants.bitcoin_upper')
: seletctedToken?.tokenMetadata?.tokenTicker;
if (
(!Object.keys(paymentInfo).length && !errorMessage) ||
!sparkInformation.didConnect
@@ -1501,9 +1514,7 @@ export default function SendPaymentScreen(props) {
forceCurrency={primaryDisplay.forceCurrency}
forceFiatStats={primaryDisplay.forceFiatStats}
activeOpacity={!sendingAmount ? 0.5 : 1}
customCurrencyCode={
isUsingLRC20 ? seletctedToken?.tokenMetadata?.tokenTicker : ''
}
customCurrencyCode={displayTokenTicker}
maxDecimals={isUsingLRC20 ? tokenDecimals : 2}
/>
{uiState === 'CONFIRM_PAYMENT' && !isUsingLRC20 && (
@@ -1549,13 +1560,16 @@ export default function SendPaymentScreen(props) {
{uiState === 'CONFIRM_PAYMENT' && (
<InvoiceInfo
paymentInfo={paymentInfo}
contactInfo={contactInfo || paymentInfo?.blitzContactInfo}
fromPage={
fromPage || (paymentInfo?.blitzContactInfo ? 'contacts' : '')
}
contactInfo={selectedContactInfo}
fromPage={fromPage || (selectedContactInfo ? 'contacts' : '')}
theme={theme}
darkModeType={darkModeType}
isUsingBranta={isUsingBranta}
isDollarBalance={
resolvedPaymentMethod === 'USD' ||
selectedLRC20Asset === USDB_TOKEN_ID
}
isToken={isUsingLRC20 && selectedLRC20Asset !== USDB_TOKEN_ID}
/>
)}
{uiState === 'CHOOSE_METHOD' && (
@@ -502,6 +502,7 @@ export default function StablecoinSendScreen() {
transaction: pendingTx,
paymentDisplay: primaryDisplayRef.current,
displayAmount: rawInput,
stablecoinInfo: { asset, label: chainLabel },
},
},
],
@@ -528,6 +529,7 @@ export default function StablecoinSendScreen() {
error: err.message,
lnurlAddress: undefined,
blitzContactInfo: undefined,
stablecoinInfo: { asset, label: chainLabel },
},
},
],
@@ -553,6 +555,8 @@ export default function StablecoinSendScreen() {
clearCountdown,
t,
rawInput,
chainLabel,
asset,
]);
const handleEmoji = newDescription => {
@@ -780,11 +784,11 @@ export default function StablecoinSendScreen() {
content={`${truncateAddress(address)}`}
/>
</View>
<ThemeIcon
{/* <ThemeIcon
iconName="ChevronRight"
size={20}
styles={styles.destinationChevron}
/>
/> */}
</TouchableOpacity>
)}
</ScrollView>
@@ -926,20 +930,22 @@ const styles = StyleSheet.create({
width: '80%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
justifyContent: 'center',
padding: 12,
borderRadius: 16,
...CENTER,
marginTop: 30,
},
destinationContent: {
flexShrink: 1,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
destinationChevron: {
opacity: 0.8,
marginLeft: 8,
position: 'absolute',
right: 12,
},
receiveAmount: {
opacity: HIDDEN_OPACITY,
@@ -953,6 +959,7 @@ const styles = StyleSheet.create({
borderRadius: 20,
},
quoteValue: {
flexShrink: 1,
fontSize: SIZES.medium,
includeFontPadding: false,
},
@@ -62,6 +62,30 @@ export function getPhonePaymentCountry(address) {
return match ? match[0] : null;
}
// Human-readable display for a phone-payment provider address (either LNURL or
// POST provider). Returns the provider country iso code (for a flag) and the
// phone number in international format, or null for non-phone addresses. The
// local part is national for some providers (ZM -> 0977…), so we parse trying
// the international form first, then the resolved country.
export function getPhonePaymentDisplay(address) {
const isoCode = getPhonePaymentCountry(address);
if (!isoCode) return null;
const local = address.slice(0, address.indexOf('@'));
const attempts = [
[local.startsWith('+') ? local : `+${local}`, undefined],
[local, isoCode],
];
for (const [value, defaultCountry] of attempts) {
try {
const parsed = parsePhoneNumberWithError(value, defaultCountry);
if (parsed.isValid()) {
return { isoCode, formatted: parsed.formatInternational() };
}
} catch {}
}
return { isoCode, formatted: local };
}
// Returns the provider lightning addresses the input is valid for, in
// PHONE_PAYMENT_PROVIDERS order (KE before ZM). Accepts national or
// international input. A bare national number in the overlapping 075/076/077
+205 -309
View File
@@ -1,4 +1,4 @@
import { StyleSheet, View, TouchableOpacity, ScrollView } from 'react-native';
import { StyleSheet, View, TouchableOpacity } from 'react-native';
import { CENTER, COLORS, FONT, SIZES } from '../../constants';
import { useNavigation } from '@react-navigation/native';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -20,29 +20,28 @@ import formatTokensNumber from '../../functions/lrc20/formatTokensBalance';
import { useTranslation } from 'react-i18next';
import { useAppStatus } from '../../../context-store/appStatus';
import DropdownMenu from '../../functions/CustomElements/dropdownMenu';
import displayCorrectDenomination from '../../functions/displayCorrectDenomination';
import { useGlobalContextProvider } from '../../../context-store/context';
import { useNodeContext } from '../../../context-store/nodeContext';
import customUUID from '../../functions/customUUID';
import { useGlobalContactsInfo } from '../../../context-store/globalContacts';
import { getSingleContact } from '../../../db';
import { getCachedProfileImage } from '../../functions/cachedImage';
import { HIDDEN_OPACITY, INSET_WINDOW_WIDTH } from '../../constants/theme';
import { INSET_WINDOW_WIDTH } from '../../constants/theme';
import normalizeLNURLAddress from '../../functions/lnurl/normalizeLNURLAddress';
import { isBlitzLNURLAddress } from '../../functions/lnurl';
import { canonicalizePhonePaymentAddress } from '../../functions/sendBitcoin/getPhonePaymentAddress';
import FormattedBalanceInput from '../../functions/CustomElements/formattedBalanceInput';
import {
RecipientAvatar,
resolveRecipientDisplay,
} from '../../components/admin/homeComponents/sendBitcoin/components/recipientCard';
const confirmTxAnimation = require('../../assets/confirmTxAnimation.json');
const errorTxAnimation = require('../../assets/errorTxAnimation.json');
export default function ConfirmTxPage(props) {
const { sparkInformation } = useSparkWallet();
const { screenDimensions } = useAppStatus();
const { masterInfoObject } = useGlobalContextProvider();
const { fiatStats } = useNodeContext();
const navigate = useNavigation();
const { showToast } = useToast();
const { backgroundOffset, textColor } = GetThemeColors();
const { backgroundOffset, backgroundColor, textColor } = GetThemeColors();
const { theme, darkModeType } = useGlobalThemeContext();
const animationRef = useRef(null);
const { t } = useTranslation();
@@ -57,9 +56,13 @@ export default function ConfirmTxPage(props) {
const lnurlUsername = lnurlAddress?.split('@')[0]?.toLowerCase();
const blitzContactInfo = props.route.params?.blitzContactInfo;
const displayAmount = props.route.params?.displayAmount;
const stablecoinInfo = props.route.params?.stablecoinInfo;
// The display currency the user entered/reviewed the payment in (e.g. EUR),
// passed from the send screens so the success amount matches what they saw.
const paymentDisplay = props.route.params?.paymentDisplay;
// Set only when the send screen actually labelled the amount with a token
// ticker. USDB-funded sends carry token info on the tx but were shown as fiat.
const displayTokenTicker = props.route.params?.displayTokenTicker;
const didSucceed = !hasError || isLNURLAuth;
@@ -87,18 +90,39 @@ export default function ConfirmTxPage(props) {
const showAddBlitzContact =
didSucceed && !isLNURLAuth && blitzContactInfo && !isAlreadyBlitzContact;
const paymentNetwork = paymentInformation?.sendingUUID
? t('screens.inAccount.expandedTxPage.contactPaymentType')
: paymentInformation?.isGift
? t('constants.gift')
: transaction?.paymentType;
// Only on-chain and cross-chain stablecoin sends actually leave the user
// waiting — a lightning tx is written 'pending' but settles immediately.
const showPendingMessage =
transaction?.paymentStatus === 'pending' &&
(paymentInformation?.isFlashnetStablecoin ||
transaction?.paymentType === 'bitcoin');
const showPendingMessage = transaction?.paymentStatus === 'pending';
const isFlashnetStablecoinPending =
paymentInformation?.isFlashnetStablecoin &&
transaction?.paymentStatus === 'pending';
// Recipient "name card": show a real avatar (branta logo, LNURL provider logo,
// contact image, or mobile-money country flag) + name for single-recipient sends.
// All the data is already in the route params / tx details — no extra wiring.
const recipientResolved = resolveRecipientDisplay({
lnurlAddress,
contactInfo: blitzContactInfo,
brantaName: paymentInformation?.brantaMerchantName,
brantaLogo: paymentInformation?.brantaMerchantLogo,
transaction,
stablecoinInfo,
});
const showRecipientCard = !isLNURLAuth && !!recipientResolved;
const paymentFee = paymentInformation?.fee;
const statusSubtitle = isLNURLAuth
? t('screens.inAccount.confirmTxPage.lnurlAuthSuccess')
: !didSucceed
? t('screens.inAccount.confirmTxPage.paymentErrorMessage')
: showPendingMessage
? t('screens.inAccount.confirmTxPage.sendingInProgress')
: t('screens.inAccount.confirmTxPage.confirmMessage', {
context:
paymentInformation.direction?.toLowerCase() === 'outgoing'
? 'sent'
: 'received',
});
const buttonText = t('constants.done');
const errorMessage = hasError || t('errormessages.genericError');
@@ -132,102 +156,6 @@ export default function ConfirmTxPage(props) {
animationRef.current?.play();
}, []);
if (isFlashnetStablecoinPending) {
return (
<GlobalThemeView useStandardWidth={true} styles={styles.globalConatianer}>
<LottieView
ref={animationRef}
source={didSucceed ? confirmAnimation : errorAnimation}
loop={false}
style={{
width: screenDimensions.width / 1.5,
height: screenDimensions.width / 1.5,
maxWidth: 400,
maxHeight: 400,
}}
/>
<ThemeText
styles={{ fontSize: SIZES.large, marginBottom: 10 }}
content={t('wallet.stablecoinSend.swapInProgress')}
/>
<ThemeText
styles={{
opacity: HIDDEN_OPACITY,
width: '95%',
maxWidth: 300,
textAlign: 'center',
marginBottom: 50,
fontSize: SIZES.smedium,
}}
content={t('wallet.stablecoinSend.swapInProgressDescription')}
/>
<View style={styles.paymentTable}>
<View style={styles.paymentTableRow}>
<ThemeText
CustomNumberOfLines={1}
styles={styles.labelText}
content={t('constants.fee')}
/>
<ThemeText
content={displayCorrectDenomination({
amount: paymentFee,
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination: paymentDisplay?.denomination
? paymentDisplay.denomination
: masterInfoObject.userBalanceDenomination,
},
fiatStats: paymentDisplay?.paymentDisplay
? paymentDisplay.forceFiatStats
: fiatStats,
forceCurrency: paymentDisplay?.forceCurrency
? paymentDisplay.forceCurrency
: undefined,
})}
/>
</View>
<View style={styles.paymentTableRow}>
<ThemeText
CustomNumberOfLines={1}
styles={styles.labelText}
content={t('constants.type')}
/>
<ThemeText
styles={{ textTransform: 'capitalize' }}
content={t('screens.inAccount.expandedTxPage.chainSwap', {
context:
paymentInformation.destinationChain === 'lightning'
? 'lightning'
: 'other',
})}
/>
</View>
</View>
<CustomButton
buttonStyles={{
width: INSET_WINDOW_WIDTH,
backgroundColor: !theme ? COLORS.primary : COLORS.darkModeText,
marginTop: 'auto',
paddingHorizontal: 15,
}}
textStyles={{
...styles.buttonText,
color: !theme ? COLORS.darkModeText : COLORS.lightModeText,
}}
actionFunction={() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
navigate.popToTop();
});
});
}}
textContent={t('constants.continue')}
/>
</GlobalThemeView>
);
}
if (props.route.params?.isSplitPayment && props.route.params?.isRequset) {
return (
<GlobalThemeView useStandardWidth={true} styles={styles.globalConatianer}>
@@ -272,204 +200,128 @@ export default function ConfirmTxPage(props) {
return (
<GlobalThemeView useStandardWidth={true} styles={styles.globalConatianer}>
<LottieView
ref={animationRef}
source={didSucceed ? confirmAnimation : errorAnimation}
loop={false}
style={{
width: screenDimensions.width / 1.5,
height: screenDimensions.width / 1.5,
maxWidth: 400,
maxHeight: 400,
}}
/>
{!isLNURLAuth && (
<ThemeText
styles={{ fontSize: SIZES.large, marginBottom: 10 }}
content={
!didSucceed
? t('screens.inAccount.confirmTxPage.failedToSend')
: t('screens.inAccount.confirmTxPage.confirmMessage', {
context:
paymentInformation.direction?.toLowerCase() === 'outgoing'
? 'sent'
: 'received',
})
}
<View style={styles.contentContainer}>
<LottieView
ref={animationRef}
source={didSucceed ? confirmAnimation : errorAnimation}
loop={false}
style={{
width: 125,
height: 125,
}}
/>
)}
{(didSucceed || amount > 0) && !isLNURLAuth && (
<View style={{ marginBottom: 10 }}>
{displayAmount && paymentDisplay ? (
<FormattedBalanceInput
maxWidth={0.7}
amountValue={displayAmount}
inputDenomination={paymentDisplay.denomination}
forceCurrency={paymentDisplay.forceCurrency}
forceFiatStats={paymentDisplay.forceFiatStats}
customCurrencyCode={displayTokenTicker}
maxDecimals={
displayTokenTicker ? token?.tokenMetadata?.decimals ?? 0 : 2
}
/>
) : (
<FormattedSatText
styles={{
fontSize: 45,
includeFontPadding: false,
}}
neverHideBalance={true}
balance={isLRC20Payment ? formattedTokensBalance : amount}
useCustomLabel={isLRC20Payment}
customLabel={token?.tokenMetadata?.tokenTicker}
useMillionDenomination={true}
globalBalanceDenomination={
paymentDisplay && !isLRC20Payment
? paymentDisplay.denomination
: undefined
}
forceCurrency={
paymentDisplay && !isLRC20Payment
? paymentDisplay.forceCurrency
: null
}
forceFiatStats={
paymentDisplay && !isLRC20Payment
? paymentDisplay.forceFiatStats
: null
}
/>
)}
</View>
)}
{didSucceed && !isLNURLAuth && (
<View style={{ marginBottom: 10 }}>
{displayAmount && paymentDisplay ? (
<FormattedBalanceInput
maxWidth={0.7}
amountValue={displayAmount}
inputDenomination={paymentDisplay.denomination}
forceCurrency={paymentDisplay.forceCurrency}
forceFiatStats={paymentDisplay.forceFiatStats}
customCurrencyCode={token?.tokenMetadata?.tokenTicker}
maxDecimals={
isLRC20Payment ? token?.tokenMetadata?.decimals ?? 0 : 2
}
/>
) : (
<FormattedSatText
styles={{
fontSize: 45,
includeFontPadding: false,
}}
neverHideBalance={true}
balance={isLRC20Payment ? formattedTokensBalance : amount}
useCustomLabel={isLRC20Payment}
customLabel={token?.tokenMetadata?.tokenTicker}
useMillionDenomination={true}
globalBalanceDenomination={
paymentDisplay && !isLRC20Payment
? paymentDisplay.denomination
: undefined
}
forceCurrency={
paymentDisplay && !isLRC20Payment
? paymentDisplay.forceCurrency
: null
}
forceFiatStats={
paymentDisplay && !isLRC20Payment
? paymentDisplay.forceFiatStats
: null
}
/>
)}
</View>
)}
{isLNURLAuth && (
<ThemeText
styles={{
fontFamily: FONT.Title_Medium,
fontSize: SIZES.large,
width: '95%',
textAlign: 'center',
marginTop: 20,
marginBottom: 10,
}}
content={t('screens.inAccount.confirmTxPage.walletConnected')}
/>
)}
{!didSucceed && (
<ThemeText
styles={{
fontFamily: FONT.Title_Medium,
fontSize: SIZES.large,
width: '95%',
textAlign: 'center',
marginTop: 20,
marginBottom: 10,
}}
content={t('screens.inAccount.confirmTxPage.failedToSend')}
/>
)}
{isLNURLAuth && (
<ThemeText
styles={{
opacity: 0.6,
width: '95%',
maxWidth: 300,
textAlign: 'center',
marginBottom: 40,
}}
content={t('screens.inAccount.confirmTxPage.lnurlAuthSuccess')}
content={statusSubtitle}
/>
)}
<ThemeText
styles={{
opacity: 0.6,
width: '95%',
maxWidth: 300,
textAlign: 'center',
marginBottom: 40,
}}
content={
didSucceed
? ''
: t('screens.inAccount.confirmTxPage.paymentErrorMessage')
}
/>
{didSucceed && !isLNURLAuth && (
<View style={styles.paymentTable}>
<View style={styles.paymentTableRow}>
<ThemeText
CustomNumberOfLines={1}
styles={styles.labelText}
content={t('constants.fee')}
/>
<ThemeText
content={displayCorrectDenomination({
amount: paymentFee,
masterInfoObject: {
...masterInfoObject,
userBalanceDenomination: paymentDisplay?.denomination
? paymentDisplay.denomination
: masterInfoObject.userBalanceDenomination,
},
fiatStats: paymentDisplay?.paymentDisplay
? paymentDisplay.forceFiatStats
: fiatStats,
forceCurrency: paymentDisplay?.forceCurrency
? paymentDisplay.forceCurrency
: undefined,
})}
/>
</View>
<View style={styles.paymentTableRow}>
<ThemeText
CustomNumberOfLines={1}
styles={styles.labelText}
content={t('constants.type')}
/>
<ThemeText
styles={{ textTransform: 'capitalize' }}
content={paymentNetwork}
/>
</View>
</View>
)}
{!didSucceed && !isLNURLAuth && (
<View
style={{
flex: 1,
backgroundColor: backgroundOffset,
borderRadius: 8,
width: '95%',
maxWidth: 300,
minHeight: 100,
}}
>
<ScrollView contentContainerStyle={{ padding: 10 }}>
<ThemeText content={t('errormessages.paymentError')} />
</ScrollView>
</View>
)}
{!didSucceed && !isLNURLAuth && (
<View style={{ marginTop: 10, marginBottom: 20 }}>
<DropdownMenu
options={[
{
label: t('screens.inAccount.confirmTxPage.emailReport'),
value: 'email',
},
{
label: t('screens.inAccount.confirmTxPage.copyReport'),
value: 'clipboard',
},
{showRecipientCard && (
<TouchableOpacity
activeOpacity={recipientResolved.fullAddress ? 0.6 : 1}
disabled={!recipientResolved.fullAddress}
onPress={() =>
navigate.navigate('ErrorScreen', {
errorMessage: recipientResolved.fullAddress,
})
}
style={[
styles.recipientCard,
{ backgroundColor: backgroundOffset },
]}
selectedValue=""
placeholder={t('screens.inAccount.confirmTxPage.sendReport')}
onSelect={async item => {
if (item.value === 'email') {
try {
await openComposer({
to: 'blake@blitzwalletapp.com',
subject: 'Payment Failed',
body: String(errorMessage),
});
} catch (err) {
console.log('Email composer error:', err);
}
} else if (item.value === 'clipboard') {
copyToClipboard(String(errorMessage), showToast);
}
}}
showClearIcon={false}
showVerticalArrows={false}
translateLabelText={false}
customButtonStyles={{
backgroundColor: 'transparent',
}}
textStyles={{
...CENTER,
textDecorationLine: 'underline',
}}
/>
</View>
)}
>
<RecipientAvatar
resolved={recipientResolved}
theme={theme}
darkModeType={darkModeType}
size={30}
backgroundColor={backgroundColor}
/>
<ThemeText
styles={styles.recipientName}
CustomNumberOfLines={1}
content={recipientResolved.displayName}
/>
</TouchableOpacity>
)}
</View>
<CustomButton
buttonStyles={{
width: INSET_WINDOW_WIDTH,
@@ -491,9 +343,50 @@ export default function ConfirmTxPage(props) {
});
});
}}
textContent={t('constants.continue')}
textContent={buttonText}
/>
{!didSucceed && !isLNURLAuth && (
<DropdownMenu
options={[
{
label: t('screens.inAccount.confirmTxPage.emailReport'),
value: 'email',
},
{
label: t('screens.inAccount.confirmTxPage.copyReport'),
value: 'clipboard',
},
]}
selectedValue=""
placeholder={t('screens.inAccount.confirmTxPage.sendReport')}
onSelect={async item => {
if (item.value === 'email') {
try {
await openComposer({
to: 'blake@blitzwalletapp.com',
subject: 'Payment Failed',
body: String(errorMessage),
});
} catch (err) {
console.log('Email composer error:', err);
}
} else if (item.value === 'clipboard') {
copyToClipboard(String(errorMessage), showToast);
}
}}
showClearIcon={false}
showVerticalArrows={false}
translateLabelText={false}
customButtonStyles={{
backgroundColor: 'transparent',
}}
textStyles={{
...CENTER,
}}
/>
)}
{showAddContact && (
<CustomButton
textStyles={{ color: textColor }}
@@ -588,6 +481,7 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: 'center',
},
contentContainer: { flex: 1, alignItems: 'center', justifyContent: 'center' },
buttonText: {
fontFamily: FONT.Descriptoin_Regular,
},
@@ -603,17 +497,19 @@ const styles = StyleSheet.create({
width: 300, // adjust as necessary
height: 300, // adjust as necessary
},
paymentTable: {
rowGap: 20,
},
paymentTableRow: {
width: '100%',
minWidth: 200,
recipientCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
alignSelf: 'center',
maxWidth: '90%',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 100,
columnGap: 10,
},
labelText: {
recipientName: {
flexShrink: 1,
marginRight: 5,
includeFontPadding: false,
fontSize: SIZES.smedium,
},
});
+9 -4
View File
@@ -1231,7 +1231,9 @@
"swapRatesChangedButton": "Betrag erneut eingeben",
"feeEstimateError": "Gebühr konnte nicht geschätzt werden, bitte versuchen Sie es erneut",
"brantaVerification": "Diese Zahlung ist von Branta verifiziert.",
"lightningPayment": "BTC-Zahlung"
"lightningPayment": "BTC-Zahlung",
"dollarPayment": "Dollar-Zahlung",
"tokenPayment": "Token-Zahlung"
},
"selectPaymentMethod": {
"header": "Wie möchten Sie diese Zahlung senden (BTC oder USD)?"
@@ -2140,15 +2142,18 @@
"confirmTxPage": {
"failedToSend": "Senden fehlgeschlagen",
"paymentErrorMessage": "Es gab ein Problem beim Senden dieser Zahlung, bitte versuchen Sie es erneut.",
"sendReport": "Bericht an Entwickler senden",
"lnurlAuthSuccess": "Wallet-Authentifizierung erfolgreich! Sie sind jetzt eingeloggt.",
"sendReport": "Problem melden",
"lnurlAuthSuccess": "Sie sind angemeldet und können loslegen.",
"walletConnected": "Wallet verbunden",
"emailReport": "Per E-Mail senden",
"copyReport": "In die Zwischenablage kopieren",
"confirmMessage_sent": "Erfolgreich gesendet",
"confirmMessage_received": "Erfolgreich empfangen",
"sendingInProgress": "Ihre Zahlung wurde gesendet. Bitte warten Sie, während sie bestätigt wird.",
"confirmMessage": "{{direction}} erfolgreich",
"bulkSuccess": "Anfragen erfolgreich gesendet",
"bulkPartialSuccess": "Keine Anfragen gesendet"
"bulkPartialSuccess": "Keine Anfragen gesendet",
"stablecoinDesc": "{{asset}} auf {{chain}}"
},
"expandedTxPage": {
"confirmMessage_sent": "Gesendeter Betrag",
+9 -4
View File
@@ -1231,7 +1231,9 @@
"swapRatesChangedButton": "Re-enter Amount",
"feeEstimateError": "Unable to estimate fee, please try again",
"brantaVerification": "This payment has been verified by Branta",
"lightningPayment": "Bitcoin payment"
"lightningPayment": "Bitcoin payment",
"dollarPayment": "Dollar payment",
"tokenPayment": "Token payment"
},
"selectPaymentMethod": {
"header": "How do you want to fund your payment?"
@@ -2141,14 +2143,17 @@
"failedToSend": "Failed to send",
"confirmMessage": "{{direction}} successfully",
"paymentErrorMessage": "There was an issue sending this payment, please try again.",
"sendReport": "Send report to developer",
"lnurlAuthSuccess": "Wallet authentication successful! Youre now logged in.",
"sendReport": "Report a problem",
"lnurlAuthSuccess": "You're signed in and ready to go.",
"emailReport": "Send via Email",
"copyReport": "Copy to Clipboard",
"confirmMessage_sent": "Sent successfully",
"confirmMessage_received": "Received successfully",
"sendingInProgress": "Your payment has been sent. Please wait while it's confirmed.",
"bulkSuccess": "Requests sent successfully",
"bulkPartialSuccess": "No requests sent"
"bulkPartialSuccess": "No requests sent",
"stablecoinDesc": "{{asset}} on {{chain}}",
"walletConnected": "Wallet connected"
},
"expandedTxPage": {
"confirmMessage_sent": "Sent amount",
+9 -4
View File
@@ -1239,7 +1239,9 @@
"swapRatesChangedButton": "Volver a ingresar el monto",
"feeEstimateError": "No se pudo estimar la comisión, inténtalo de nuevo",
"brantaVerification": "Este pago ha sido verificado por Branta",
"lightningPayment": "Pago Bitcoin"
"lightningPayment": "Pago Bitcoin",
"dollarPayment": "Pago en dólares",
"tokenPayment": "Pago con token"
},
"selectPaymentMethod": {
"header": "Selecciona cómo deseas financiar tu pago."
@@ -2153,15 +2155,18 @@
"confirmTxPage": {
"failedToSend": "Error al enviar",
"paymentErrorMessage": "Hubo un problema al enviar este pago, por favor intenta de nuevo.",
"sendReport": "Enviar informe al desarrollador",
"sendReport": "Informar de un problema",
"lnurlAuthSuccess": "Has iniciado sesión y todo está listo.",
"walletConnected": "Billetera conectada",
"emailReport": "Enviar por correo electrónico",
"copyReport": "Copiar al portapapeles",
"confirmMessage_sent": "Enviado correctamente",
"confirmMessage_received": "Recibido correctamente",
"confirmMessage": "{{direction}} exitosamente",
"lnurlAuthSuccess": "¡Autenticación de billetera exitosa! Ahora estás iniciado sesión.",
"bulkSuccess": "Solicitudes enviadas correctamente",
"bulkPartialSuccess": "No se enviaron solicitudes"
"bulkPartialSuccess": "No se enviaron solicitudes",
"sendingInProgress": "Tu pago ha sido enviado. Espera mientras se confirma.",
"stablecoinDesc": "{{asset}} en {{chain}}"
},
"expandedTxPage": {
"confirmMessage_sent": "Cantidad enviada",
+9 -4
View File
@@ -1239,7 +1239,9 @@
"swapRatesChangedButton": "Ressaisir le montant",
"feeEstimateError": "Impossible destimer les frais, veuillez réessayer",
"brantaVerification": "Ce paiement a été vérifié par Branta",
"lightningPayment": "Paiement Bitcoin"
"lightningPayment": "Paiement Bitcoin",
"dollarPayment": "Paiement en dollars",
"tokenPayment": "Paiement en jetons"
},
"selectPaymentMethod": {
"header": "Veuillez sélectionner comment vous souhaitez financer votre paiement."
@@ -2154,14 +2156,17 @@
"failedToSend": "Échec de l'envoi",
"confirmMessage": "{{direction}} avec succès",
"paymentErrorMessage": "Il y a eu un problème lors de l'envoi de ce paiement, veuillez réessayer.",
"sendReport": "Envoyer le rapport au développeur",
"lnurlAuthSuccess": "Authentification du portefeuille réussie ! Vous êtes maintenant connecté.",
"sendReport": "Signaler un problème",
"lnurlAuthSuccess": "Vous êtes connecté et prêt à continuer.",
"walletConnected": "Portefeuille connecté",
"emailReport": "Envoyer par courriel",
"copyReport": "Copier dans le presse-papiers",
"confirmMessage_sent": "Envoyé avec succès",
"confirmMessage_received": "Reçu avec succès",
"bulkSuccess": "Demandes envoyées avec succès",
"bulkPartialSuccess": "Aucune demande envoyée"
"bulkPartialSuccess": "Aucune demande envoyée",
"sendingInProgress": "Votre paiement a été envoyé. Veuillez patienter pendant sa confirmation.",
"stablecoinDesc": "{{asset}} sur {{chain}}"
},
"expandedTxPage": {
"confirmMessage_sent": "Montant envoyé",
+9 -4
View File
@@ -1239,7 +1239,9 @@
"swapRatesChangedButton": "Reinserisci importo",
"feeEstimateError": "Impossibile stimare la commissione, riprova",
"brantaVerification": "Questo pagamento è stato verificato da Branta",
"lightningPayment": "Pagamento Bitcoin"
"lightningPayment": "Pagamento Bitcoin",
"dollarPayment": "Pagamento in dollari",
"tokenPayment": "Pagamento con token"
},
"selectPaymentMethod": {
"header": "Seleziona come desideri finanziare il pagamento."
@@ -2153,15 +2155,18 @@
"confirmTxPage": {
"failedToSend": "Invio fallito",
"paymentErrorMessage": "C'è stato un problema nell'inviare questo pagamento, riprova.",
"sendReport": "Invia report allo sviluppatore",
"sendReport": "Segnala un problema",
"lnurlAuthSuccess": "Hai effettuato l'accesso e sei pronto.",
"walletConnected": "Wallet connesso",
"emailReport": "Invia via email",
"copyReport": "Copia negli appunti",
"confirmMessage_sent": "Inviato con successo",
"confirmMessage_received": "Ricevuto con successo",
"confirmMessage": "{{direction}} con successo",
"lnurlAuthSuccess": "Autenticazione del portafoglio successo! Ora hai effettuato l'accesso.",
"bulkSuccess": "Richieste inviate con successo",
"bulkPartialSuccess": "Nessuna richiesta inviata"
"bulkPartialSuccess": "Nessuna richiesta inviata",
"sendingInProgress": "Il tuo pagamento è stato inviato. Attendi mentre viene confermato.",
"stablecoinDesc": "{{asset}} su {{chain}}"
},
"expandedTxPage": {
"confirmMessage_received": "Importo ricevuto",
+10 -4
View File
@@ -1013,6 +1013,7 @@
"noContactDesc": "Adicione {{username}} aos seus contatos para encontrá-lo aqui no futuro, ou continue para enviar um pagamento agora.",
"phonePaymentTitle": "Pagar {{number}}",
"phonePaymentDesc": "Enviando para número de celular",
"phonePaymentDescGcash": "Enviando para número GCash",
"mobileMoneyTitle": "Pagamentos Locais",
"mobileMoneySubtitle": "Métodos de pagamento por país",
"mobileMoneyCountryTitle": "Selecione um país",
@@ -1238,7 +1239,9 @@
"swapRatesChangedButton": "Inserir valor novamente",
"feeEstimateError": "Não foi possível estimar a taxa, tente novamente",
"brantaVerification": "Este pagamento foi verificado pela Branta",
"lightningPayment": "Pagamento Bitcoin"
"lightningPayment": "Pagamento Bitcoin",
"dollarPayment": "Pagamento em dólares",
"tokenPayment": "Pagamento com token"
},
"selectPaymentMethod": {
"header": "Escolha o saldo para realizar esse pagamento"
@@ -2152,15 +2155,18 @@
"confirmTxPage": {
"failedToSend": "Falha ao enviar",
"paymentErrorMessage": "Ocorreu um problema ao enviar este pagamento. Tente novamente.",
"sendReport": "Enviar relatório ao desenvolvedor",
"lnurlAuthSuccess": "Autenticação da carteira realizada com sucesso! Você está conectado.",
"sendReport": "Relatar um problema",
"lnurlAuthSuccess": "Você está conectado e tudo está pronto.",
"walletConnected": "Carteira conectada",
"emailReport": "Enviar por e-mail",
"copyReport": "Copiar para a área de transferência",
"confirmMessage_sent": "Enviado com sucesso",
"confirmMessage_received": "Recebido com sucesso",
"confirmMessage": "{{direction}} com sucesso",
"bulkSuccess": "Solicitações enviadas com sucesso",
"bulkPartialSuccess": "Nenhuma solicitação enviada"
"bulkPartialSuccess": "Nenhuma solicitação enviada",
"sendingInProgress": "Seu pagamento foi enviado. Aguarde enquanto ele é confirmado.",
"stablecoinDesc": "{{asset}} na {{chain}}"
},
"expandedTxPage": {
"confirmMessage_sent": "Valor enviado",
+9 -4
View File
@@ -1240,7 +1240,9 @@
"swapRatesChangedButton": "Ввести сумму снова",
"feeEstimateError": "Не удалось оценить комиссию, попробуйте снова",
"brantaVerification": "Этот платёж был подтверждён Branta",
"lightningPayment": "Платёж Bitcoin"
"lightningPayment": "Платёж Bitcoin",
"dollarPayment": "Платёж в долларах",
"tokenPayment": "Платёж токенами"
},
"selectPaymentMethod": {
"header": "Выберите источник средств."
@@ -2155,14 +2157,17 @@
"failedToSend": "Не удалось отправить",
"confirmMessage": "{{direction}} успешно",
"paymentErrorMessage": "Проблема с отправкой, попробуйте снова.",
"sendReport": "Отправить отчет разработчику",
"lnurlAuthSuccess": "Вход выполнен успешно!",
"sendReport": "Сообщить о проблеме",
"lnurlAuthSuccess": "Вы вошли в систему и готовы к работе.",
"walletConnected": "Кошелёк подключён",
"emailReport": "Отправить через Email",
"copyReport": "Скопировать",
"confirmMessage_sent": "Успешно отправлено",
"confirmMessage_received": "Успешно получено",
"bulkSuccess": "Запросы успешно отправлены",
"bulkPartialSuccess": "Запросы не отправлены"
"bulkPartialSuccess": "Запросы не отправлены",
"sendingInProgress": "Ваш платёж отправлен. Пожалуйста, подождите, пока он будет подтверждён.",
"stablecoinDesc": "{{asset}} в сети {{chain}}"
},
"expandedTxPage": {
"confirmMessage_sent": "Отправлено",
+9 -4
View File
@@ -1239,7 +1239,9 @@
"swapRatesChangedButton": "Ange belopp igen",
"feeEstimateError": "Kunde inte uppskatta avgiften, försök igen",
"brantaVerification": "Denna betalning har verifierats av Branta",
"lightningPayment": "Bitcoin-betalning"
"lightningPayment": "Bitcoin-betalning",
"dollarPayment": "Dollarbetalning",
"tokenPayment": "Tokenbetalning"
},
"selectPaymentMethod": {
"header": "Välj hur du vill finansiera din betalning."
@@ -2154,14 +2156,17 @@
"failedToSend": "Misslyckades med att skicka",
"confirmMessage": "{{direction}} framgångsrikt",
"paymentErrorMessage": "Det uppstod ett problem med att skicka betalningen, vänligen försök igen.",
"sendReport": "Skicka rapporten till utvecklaren",
"lnurlAuthSuccess": "Plånboksautentisering framgångsrik! Du är nu inloggad.",
"sendReport": "Rapportera ett problem",
"lnurlAuthSuccess": "Du är inloggad och redo att köra.",
"walletConnected": "Plånbok ansluten",
"emailReport": "Skicka via e-post",
"copyReport": "Kopiera till Urklipp",
"confirmMessage_sent": "Skickat framgångsrikt",
"confirmMessage_received": "Mottaget framgångsrikt",
"bulkSuccess": "Förfrågningar skickades",
"bulkPartialSuccess": "Inga förfrågningar skickades"
"bulkPartialSuccess": "Inga förfrågningar skickades",
"sendingInProgress": "Din betalning har skickats. Vänta medan den bekräftas.",
"stablecoinDesc": "{{asset}} på {{chain}}"
},
"expandedTxPage": {
"confirmMessage_sent": "Utskickat belopp",