From c2a0fce581823ccf8af32a399652933914412a25 Mon Sep 17 00:00:00 2001 From: Blake Kaufman <68204898+BlakeKaufman@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:34:25 -0400 Subject: [PATCH] Simplify payment confirmation screen (#1029) * update translations * simplifying payments display * remove unconsistant icons * adding translations * fix usdb value display * fix tokens bug --- __tests__/confirmTxPage.test.js | 123 ++++- .../getPhonePaymentAddress.test.js | 29 + __tests__/recipientCard.test.js | 164 ++++++ .../sendBitcoin/components/invoiceInfo.js | 141 ++--- .../sendBitcoin/components/recipientCard.js | 260 +++++++++ .../sendBitcoin/sendPaymentScreen.js | 52 +- .../sendBitcoin/stablecoinSendScreen.js | 17 +- .../sendBitcoin/getPhonePaymentAddress.js | 24 + app/screens/inAccount/confirmTxPage.js | 514 +++++++----------- locales/de-DE/translation.json | 13 +- locales/en/translation.json | 13 +- locales/es/translation.json | 13 +- locales/fr/translation.json | 13 +- locales/it/translation.json | 13 +- locales/pt-BR/translation.json | 14 +- locales/ru/translation.json | 13 +- locales/sv/translation.json | 13 +- 17 files changed, 954 insertions(+), 475 deletions(-) create mode 100644 __tests__/recipientCard.test.js create mode 100644 app/components/admin/homeComponents/sendBitcoin/components/recipientCard.js diff --git a/__tests__/confirmTxPage.test.js b/__tests__/confirmTxPage.test.js index 0c63a245..8c703c8a 100644 --- a/__tests__/confirmTxPage.test.js +++ b/__tests__/confirmTxPage.test.js @@ -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', + ); + }); }); diff --git a/__tests__/functions/sendBitcoin/getPhonePaymentAddress.test.js b/__tests__/functions/sendBitcoin/getPhonePaymentAddress.test.js index e6c588e1..aa09138b 100644 --- a/__tests__/functions/sendBitcoin/getPhonePaymentAddress.test.js +++ b/__tests__/functions/sendBitcoin/getPhonePaymentAddress.test.js @@ -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(); + }); +}); diff --git a/__tests__/recipientCard.test.js b/__tests__/recipientCard.test.js new file mode 100644 index 00000000..4f28461e --- /dev/null +++ b/__tests__/recipientCard.test.js @@ -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' }); + }); +}); diff --git a/app/components/admin/homeComponents/sendBitcoin/components/invoiceInfo.js b/app/components/admin/homeComponents/sendBitcoin/components/invoiceInfo.js index c9a9b5e7..97c5b61d 100644 --- a/app/components/admin/homeComponents/sendBitcoin/components/invoiceInfo.js +++ b/app/components/admin/homeComponents/sendBitcoin/components/invoiceInfo.js @@ -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 = ( - - {providerIconKey ? ( - - ) : ( - - )} - - - - ); - } else if (paymentType === 'lightning') { - paymentContent = ( - - - + + + ); + } else if (paymentType === 'lightning' || paymentType === 'spark') { + // Same avatar the success screen uses, so the icon can't drift between the + // two screens. + paymentContent = ( + + + + + ); } 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 = ( + {/* 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. */} + + + ); + } + + if (isProvider) { + return ( + + + + ); + } + + if (resolved?.kind === 'spark') { + return ( + + + + ); + } + + if (resolved?.kind === 'stablecoin') { + return ( + + + + ); + } + + if (resolved?.kind === 'asset') { + const isDollar = resolved.asset === 'dollar'; + return ( + + + + ); + } + + // branta / contact / lightning fallback all go through ContactProfileImage, + // which renders the user-icon fallback when there is no uri. + return ( + + + + ); +} + +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%', + }, +}); diff --git a/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js b/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js index 84f95081..ae851a74 100644 --- a/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js +++ b/app/components/admin/homeComponents/sendBitcoin/sendPaymentScreen.js @@ -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' && ( )} {uiState === 'CHOOSE_METHOD' && ( diff --git a/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js b/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js index f7a86cc9..ed6ee0d2 100644 --- a/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js +++ b/app/components/admin/homeComponents/sendBitcoin/stablecoinSendScreen.js @@ -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)}`} /> - + /> */} )} @@ -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, }, diff --git a/app/functions/sendBitcoin/getPhonePaymentAddress.js b/app/functions/sendBitcoin/getPhonePaymentAddress.js index ba6fc80f..15667240 100644 --- a/app/functions/sendBitcoin/getPhonePaymentAddress.js +++ b/app/functions/sendBitcoin/getPhonePaymentAddress.js @@ -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 diff --git a/app/screens/inAccount/confirmTxPage.js b/app/screens/inAccount/confirmTxPage.js index d95c8a03..65f98fc9 100644 --- a/app/screens/inAccount/confirmTxPage.js +++ b/app/screens/inAccount/confirmTxPage.js @@ -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 ( - - - - - - - - - - - - - - - - { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - navigate.popToTop(); - }); - }); - }} - textContent={t('constants.continue')} - /> - - ); - } - if (props.route.params?.isSplitPayment && props.route.params?.isRequset) { return ( @@ -272,204 +200,128 @@ export default function ConfirmTxPage(props) { return ( - - {!isLNURLAuth && ( - + - )} + {(didSucceed || amount > 0) && !isLNURLAuth && ( + + {displayAmount && paymentDisplay ? ( + + ) : ( + + )} + + )} - {didSucceed && !isLNURLAuth && ( - - {displayAmount && paymentDisplay ? ( - - ) : ( - - )} - - )} + {isLNURLAuth && ( + + )} + {!didSucceed && ( + + )} - {isLNURLAuth && ( - )} - - - {didSucceed && !isLNURLAuth && ( - - - - - - - - - - - )} - {!didSucceed && !isLNURLAuth && ( - - - - - - )} - {!didSucceed && !isLNURLAuth && ( - - + 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', - }} - /> - - )} - + > + + + + )} + + {!didSucceed && !isLNURLAuth && ( + { + 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 && (