import Clipboard from '@react-native-clipboard/clipboard' import { lt } from 'biggystring' import type { EdgeAccount, EdgeCurrencyWallet, EdgeDenomination, EdgeEncodeUri, EdgeTokenId } from 'edge-core-js' import * as React from 'react' import { ActivityIndicator, Linking, Platform, View } from 'react-native' import Share, { type ShareOptions } from 'react-native-share' import { sprintf } from 'sprintf-js' import { refreshAllFioAddresses } from '../../actions/FioAddressActions' import { toggleAccountBalanceVisibility } from '../../actions/LocalSettingsActions' import { selectWalletToken } from '../../actions/WalletActions' import { getSpecialCurrencyInfo, SPECIAL_CURRENCY_INFO } from '../../constants/WalletAndCurrencyConstants' import { useIconColor } from '../../hooks/useIconColor' import { lstrings } from '../../locales/strings' import { selectDisplayDenom } from '../../selectors/DenominationSelectors' import { getExchangeRate } from '../../selectors/WalletSelectors' import { config } from '../../theme/appConfig' import { useDispatch, useSelector } from '../../types/reactRedux' import type { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes' import type { StringMap } from '../../types/types' import { getCurrencyCode, isKeysOnlyPlugin } from '../../util/CurrencyInfoHelpers' import { getAvailableBalance, getWalletName } from '../../util/CurrencyWalletHelpers' import { triggerHaptic } from '../../util/haptic' import { convertNativeToDenomination, darkenHexColor, truncateDecimals, zeroString } from '../../util/utils' import { openBrowserUri } from '../../util/WebUtils' import { ButtonsView } from '../buttons/ButtonsView' import { EdgeButton } from '../buttons/EdgeButton' import { EdgeCard } from '../cards/EdgeCard' import { DividerLineUi4 } from '../common/DividerLineUi4' import type { AccentColors } from '../common/DotsBackground' import { EdgeAnim, fadeInDown50, fadeInDown75, fadeInUp25, fadeInUp50, fadeInUp80 } from '../common/EdgeAnim' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { SceneWrapper } from '../common/SceneWrapper' import { withWallet } from '../hoc/withWallet' import { ChatBubblesIcon, ChevronRightIcon } from '../icons/ThemedIcons' import { AddressModal } from '../modals/AddressModal' import { ButtonsModal } from '../modals/ButtonsModal' import { WalletListModal, type WalletListResult } from '../modals/WalletListModal' import { Airship, showError, showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, type ThemeProps, useTheme } from '../services/ThemeContext' import { FiatText } from '../text/FiatText' import { UnscaledText } from '../text/UnscaledText' import { AddressQr } from '../themed/AddressQr' import { EdgeText } from '../themed/EdgeText' import { ExchangedFlipInput2, type ExchangedFlipInputAmounts, type ExchangedFlipInputRef } from '../themed/ExchangedFlipInput2' import { QrCarousel } from '../themed/QrCarousel' import { SceneHeader } from '../themed/SceneHeader' import { ShareButtons } from '../themed/ShareButtons' export interface RequestParams { tokenId: EdgeTokenId walletId: string } interface OwnProps extends EdgeAppSceneProps<'request'> { wallet: EdgeCurrencyWallet } interface StateProps { account?: EdgeAccount currencyCode: string displayDenomination: EdgeDenomination exchangeSecondaryToPrimaryRatio: number fioAddressesExist: boolean isConnected: boolean isLightAccount: boolean showBalance: boolean wallet: EdgeCurrencyWallet } interface DispatchProps { refreshAllFioAddresses: () => Promise onSelectWallet: (walletId: string, tokenId: EdgeTokenId) => Promise toggleAccountBalanceVisibility: () => Promise } type ModalState = 'NOT_YET_SHOWN' | 'VISIBLE' | 'SHOWN' type CurrencyMinimumPopupState = Record interface HookProps { iconColor?: string } type Props = StateProps & DispatchProps & OwnProps & ThemeProps interface State { addresses: AddressInfo[] selectedAddress?: AddressInfo amounts?: ExchangedFlipInputAmounts minimumPopupModalState: CurrencyMinimumPopupState isFioMode: boolean errorMessage?: string } interface AddressInfo { addressString: string label: string } export class RequestSceneComponent extends React.Component< Props & HookProps, State > { flipInputRef: React.RefObject unsubscribeAddressChanged: (() => void) | undefined constructor(props: Props) { super(props) this.flipInputRef = React.createRef() const minimumPopupModalState: CurrencyMinimumPopupState = {} Object.keys(SPECIAL_CURRENCY_INFO).forEach(pluginId => { if (getSpecialCurrencyInfo(pluginId).minimumPopupModals != null) { minimumPopupModalState[pluginId] = 'NOT_YET_SHOWN' } }) this.state = { addresses: [], minimumPopupModalState, isFioMode: false } if (this.shouldShowMinimumModal(props)) { const { wallet } = props if (wallet == null) return this.state.minimumPopupModalState[wallet.currencyInfo.pluginId] = 'VISIBLE' this.enqueueMinimumAmountModal().catch((err: unknown) => { showError(err) }) } } componentDidMount(): void { this.getAddressItems().catch((err: unknown) => { showError(err) }) this.props.refreshAllFioAddresses().catch((err: unknown) => { showError(err) }) if (this.props.wallet != null) { this.unsubscribeAddressChanged = this.props.wallet.on( 'addressChanged', async () => { await this.getAddressItems() } ) } } componentWillUnmount(): void { if (this.unsubscribeAddressChanged != null) this.unsubscribeAddressChanged() } async getAddressItems(): Promise { const { wallet, currencyCode } = this.props if (currencyCode == null) return if (wallet == null) return if (isKeysOnlyPlugin(wallet.currencyInfo.pluginId)) return const addressTypeLabelMap: StringMap = { segwitAddress: lstrings.request_qr_your_segwit_address, legacyAddress: lstrings.request_qr_your_legacy_address, // Zcash saplingAddress: lstrings.request_qr_your_sapling_address, transparentAddress: lstrings.request_qr_your_transparent_address, unifiedAddress: lstrings.request_qr_your_unified_address } const allAddresses = await wallet.getAddresses({ tokenId: null }) const hasSegwitAddress = allAddresses.some( address => address.addressType === 'segwitAddress' ) const addresses: AddressInfo[] = allAddresses.map(edgeAddress => { let label: string = lstrings.request_qr_your_wallet_address if (hasSegwitAddress && edgeAddress.addressType === 'publicAddress') { label = lstrings.request_qr_your_wrapped_segwit_address } else if (addressTypeLabelMap[edgeAddress.addressType] != null) { label = addressTypeLabelMap[edgeAddress.addressType] } return { addressString: edgeAddress.publicAddress, label } }) this.setState({ addresses, selectedAddress: addresses[0] }) } async getEncodedUri(): Promise { const { wallet, currencyCode } = this.props const { amounts, selectedAddress } = this.state if (wallet == null || currencyCode == null || selectedAddress == null) return return await wallet.encodeUri({ currencyCode, publicAddress: selectedAddress.addressString, nativeAmount: amounts?.nativeAmount }) } componentDidUpdate(prevProps: Props, prevState: State): void { const { currencyCode, wallet } = this.props if (wallet == null || currencyCode == null) return const { pluginId } = wallet.currencyInfo const didAddressChange = prevState.selectedAddress !== this.state.selectedAddress const didWalletChange = prevProps.wallet != null && wallet.id !== prevProps.wallet.id if (didWalletChange) { this.getAddressItems().catch((err: unknown) => { showError(err) }) } // old blank address to new // include 'didAddressChange' because didWalletChange returns false upon initial request scene load if (didWalletChange || didAddressChange) { if (this.shouldShowMinimumModal(this.props)) { const minimumPopupModalState: CurrencyMinimumPopupState = Object.assign( {}, this.state.minimumPopupModalState ) if (minimumPopupModalState[pluginId] === 'NOT_YET_SHOWN') { this.enqueueMinimumAmountModal().catch((err: unknown) => { showError(err) }) } minimumPopupModalState[pluginId] = 'VISIBLE' this.setState({ minimumPopupModalState }) } } } enqueueMinimumAmountModal = async (): Promise => { const { wallet } = this.props if (wallet == null) return const { pluginId } = wallet.currencyInfo const { minimumPopupModals } = getSpecialCurrencyInfo(pluginId) if (minimumPopupModals == null) return await Airship.show<'ok' | undefined>(bridge => ( )) // resolve value doesn't really matter here this.setState(state => ({ minimumPopupModalState: { ...state.minimumPopupModalState, [pluginId]: 'SHOWN' } })) } handleAddressBlockExplorer = (): void => { const { wallet } = this.props const addressExplorer = wallet != null ? wallet.currencyInfo.addressExplorer : null if (this.state.selectedAddress == null) return const requestAddress = this.state.selectedAddress.addressString Airship.show<'confirm' | 'cancel' | undefined>(bridge => ( )) .then(async (result?: string) => { if (result === 'confirm' && addressExplorer != null) { const url = sprintf(addressExplorer, requestAddress) await Linking.openURL(url).catch((error: unknown) => { showError(error) }) } }) .catch((error: unknown) => { showError(error) }) } handlePickWallet = async (): Promise => { const { navigation } = this.props const result = await Airship.show(bridge => ( )) if (result?.type === 'wallet') { const { walletId, tokenId } = result navigation.setParams({ tokenId, walletId }) await this.props.onSelectWallet(walletId, tokenId) if (this.flipInputRef.current != null) { this.flipInputRef.current.setAmount( 'fiat', this.state.amounts?.fiatAmount ?? '0' ) } } } onError = (errorMessage?: string): void => { this.setState({ errorMessage }) } handleKeysOnlyModePress = async (): Promise => { await openBrowserUri(config.supportSite) } renderKeysOnlyMode = (): React.ReactNode => { const styles = getStyles(this.props.theme) return ( {sprintf( lstrings.request_deprecated_header, this.props.wallet?.currencyInfo.displayName )} {sprintf( lstrings.request_deprecated_currency_code, this.props.currencyCode )} ) } handleBackupPress = (): void => { this.props.navigation.navigate('upgradeUsername') } renderLightAccountMode = (): React.ReactNode => { const styles = getStyles(this.props.theme) return ( {sprintf(lstrings.backup_for_transfer_message, config.appName)} ) } handleChangeAddressItem = (index: number): void => { this.setState({ selectedAddress: this.state.addresses[index] }) } handleBalanceVisibility = async (): Promise => { triggerHaptic('impactLight') await this.props.toggleAccountBalanceVisibility() } render(): React.ReactNode { const { currencyCode, exchangeSecondaryToPrimaryRatio, iconColor, isLightAccount, wallet, displayDenomination, theme, route } = this.props const { tokenId } = route.params const styles = getStyles(theme) if ( currencyCode == null || exchangeSecondaryToPrimaryRatio == null || wallet == null ) { return ( ) } const selectedAddress = this.state.selectedAddress const requestAddress = selectedAddress?.addressString ?? lstrings.loading const flipInputHeaderText = sprintf( lstrings.send_to_wallet, getWalletName(wallet) ) const keysOnlyMode = isKeysOnlyPlugin(wallet.currencyInfo.pluginId) const addressExplorerDisabled = wallet.currencyInfo.addressExplorer === '' // Balance const nativeBalance = getAvailableBalance(wallet, tokenId) const displayBalanceAmount = convertNativeToDenomination( displayDenomination.multiplier )(nativeBalance) const displayBalanceString = sprintf( lstrings.request_balance, `${truncateDecimals(displayBalanceAmount)} ${displayDenomination.name}` ) // Selected denomination const denomString = `1 ${displayDenomination.name}` const accentColors: AccentColors = { // Transparent fallback for while iconColor is loading iconAccentColor: iconColor ?? '#00000000' } const backgroundColors = [...theme.assetBackgroundGradientColors] if (iconColor != null && theme.isDark) { const scaledColor = darkenHexColor( iconColor, theme.assetBackgroundColorScale ) backgroundColors[0] = scaledColor } return isLightAccount ? ( this.renderLightAccountMode() ) : keysOnlyMode ? ( this.renderKeysOnlyMode() ) : ( {lstrings.fragment_request_subtitle} {denomString} {this.props.showBalance ? ( {displayBalanceString} ) : ( {lstrings.string_show_balance} )} {this.state.errorMessage != null ? ( {this.state.errorMessage} ) : null} {this.state.addresses.length === 1 ? ( ) : ( item.addressString} onIndexChange={this.handleChangeAddressItem} renderItem={item => ( )} /> )} {selectedAddress?.label ?? lstrings.request_qr_your_wallet_address} {addressExplorerDisabled ? null : ( )} {requestAddress} ) } handleExchangeAmountChanged = (amounts: ExchangedFlipInputAmounts): void => { this.setState({ amounts }) } copyToClipboard = async (): Promise => { try { const encodedUri = await this.getEncodedUri() if (encodedUri != null) { Clipboard.setString(encodedUri) showToast(lstrings.fragment_request_address_uri_copied) } } catch (error) { showError(error) } } shouldShowMinimumModal = (props: Props): boolean => { const { currencyCode, wallet } = props if (currencyCode == null || wallet == null) return false // No minimums for tokens if (currencyCode !== wallet.currencyInfo.currencyCode) return false const { pluginId } = wallet.currencyInfo const popupState = this.state.minimumPopupModalState[pluginId] if (popupState === 'NOT_YET_SHOWN') { const { minimumPopupModals } = getSpecialCurrencyInfo(pluginId) const minBalance = minimumPopupModals != null ? minimumPopupModals.minimumNativeBalance : '0' if (lt(wallet.balanceMap.get(null) ?? '0', minBalance)) { return true } } return false } openShareModal = async (): Promise => { const { currencyCode, wallet } = this.props const { selectedAddress } = this.state if (currencyCode == null || wallet == null || selectedAddress == null) { throw new Error('Wallet still loading. Please wait and try again.') } const publicAddress = selectedAddress.addressString let sharedAddress = (await this.getEncodedUri()) ?? publicAddress let edgePayUri = 'https://deep.edge.app/' let addOnMessage = '' // if encoded (like XTZ), only share the public address if ( getSpecialCurrencyInfo(wallet.currencyInfo.pluginId) .isUriEncodedStructure === true ) { sharedAddress = publicAddress } else { // Rebuild uri to preserve uriPrefix if amount is 0 if (sharedAddress != null && !sharedAddress.includes('amount')) { const edgeEncodeUri: EdgeEncodeUri = { publicAddress, currencyCode, nativeAmount: '0' } const newUri = await wallet.encodeUri(edgeEncodeUri) sharedAddress = newUri.substring(0, newUri.indexOf('?')) } edgePayUri = edgePayUri + `pay/${sharedAddress.replace(':', '/')}` addOnMessage = `\n\n${sprintf( lstrings.request_qr_email_title, config.appName )}\n\n` } const subject = sprintf( lstrings.request_email_subject, config.appName, wallet.currencyInfo.displayName ) const message = `${sharedAddress}${addOnMessage}` const shareOptions: ShareOptions = { subject, message: Platform.OS === 'ios' ? message : message + edgePayUri, url: Platform.OS === 'ios' ? edgePayUri : '', failOnCancel: false } await Share.open(shareOptions).catch((error: unknown) => { showError(error) }) } openFioAddressModal = async (): Promise => { const { navigation, wallet, currencyCode, route } = this.props const { walletId, tokenId } = route.params if (wallet?.id == null || currencyCode == null) return if (!this.props.isConnected) { showError(lstrings.fio_network_alert_text, { trackError: false }) return } if (!this.props.fioAddressesExist) { showToast( `${lstrings.title_register_fio_address}. ${lstrings.fio_request_by_fio_address_error_no_address}` ) return } if ( this.state.amounts == null || zeroString(this.state.amounts?.nativeAmount) ) { showToast(lstrings.fio_request_by_fio_address_error_invalid_amount) return } const fioAddressTo = await Airship.show(bridge => ( )) if (fioAddressTo != null) { navigation.navigate('fioRequestConfirmation', { amounts: this.state.amounts, fioAddressTo, tokenId, walletId }) } } } const getStyles = cacheStyles((theme: Theme) => ({ keysOnlyModeHeader: { marginHorizontal: theme.rem(1), marginBottom: theme.rem(1), marginTop: theme.rem(1.5) }, keysOnlyModeHeaderText: { fontFamily: theme.fontFaceMedium, fontSize: theme.rem(1.2) }, keysOnlyModeText: { color: theme.primaryText, fontFamily: theme.fontFaceDefault, fontSize: theme.rem(1), padding: theme.rem(1), paddingTop: theme.rem(0.5) }, keysOnlyModeButtonContainer: { marginTop: theme.rem(3.5), marginBottom: theme.rem(2) }, container: { flex: 1, justifyContent: 'flex-start', paddingHorizontal: theme.rem(1) }, requestContainer: { justifyContent: 'space-between', flexDirection: 'row' }, backupText: { marginTop: theme.rem(1), flexGrow: 1, flexShrink: 0 }, balanceContainer: { justifyContent: 'space-between', flexDirection: 'row', marginBottom: theme.rem(0.5) }, balanceAmountContainer: { flex: 1, justifyContent: 'center', marginRight: theme.rem(0.25) }, exchangeRate: { textAlign: 'right', alignSelf: 'flex-end', fontFamily: theme.fontFaceBold }, qrContainer: { margin: theme.rem(1), alignSelf: 'center', flex: 1, flexDirection: 'column' }, title: { fontFamily: theme.fontFaceMedium, fontSize: theme.rem(2) }, rightChevronContainer: { flexDirection: 'row', alignItems: 'center' }, accessoryText: { color: theme.inputAccessoryText }, publicAddressText: { fontSize: theme.rem(0.75) }, loader: { flex: 1, alignSelf: 'center' }, errorText: { marginBottom: theme.rem(0.75), color: theme.dangerText } })) export const RequestScene = withWallet((props: OwnProps) => { const { route, wallet, navigation } = props const { tokenId } = route.params const currencyCode = getCurrencyCode(wallet, tokenId) const theme = useTheme() const dispatch = useDispatch() const account = useSelector(state => state.core.account) const exchangeSecondaryToPrimaryRatio = useSelector(state => getExchangeRate( state.exchangeRates, wallet.currencyInfo.pluginId, tokenId, isoFiatCurrencyCode ) ) const fioAddresses = useSelector(state => state.ui.fioAddress.fioAddresses) const isConnected = useSelector(state => state.network.isConnected) const isoFiatCurrencyCode = useSelector( state => state.ui.settings.defaultIsoFiat ) const primaryDisplayDenomination = useSelector(state => selectDisplayDenom(state, wallet.currencyConfig, tokenId) ) const showBalance = useSelector( state => state.ui.settings.isAccountBalanceVisible ) const { pluginId } = wallet.currencyInfo const iconColor = useIconColor({ pluginId, tokenId }) return ( 0} iconColor={iconColor} isConnected={isConnected} isLightAccount={account.username == null} navigation={navigation} route={route} showBalance={showBalance} theme={theme} wallet={wallet} refreshAllFioAddresses={async () => { await dispatch(refreshAllFioAddresses()) }} onSelectWallet={async (walletId: string, tokenId: EdgeTokenId) => { await dispatch( selectWalletToken({ navigation: navigation as NavigationBase, walletId, tokenId }) ) }} toggleAccountBalanceVisibility={async () => { await dispatch(toggleAccountBalanceVisibility()) }} /> ) })