diff --git a/__tests__/screens/receive.spec.tsx b/__tests__/screens/receive.spec.tsx index 118474a726..2e1d579671 100644 --- a/__tests__/screens/receive.spec.tsx +++ b/__tests__/screens/receive.spec.tsx @@ -4,6 +4,15 @@ import { act, render } from "@testing-library/react-native" import { ContextForScreen } from "./helper" import ReceiveScreen from "@app/screens/receive-bitcoin-screen/receive-screen" +jest.mock("react-native-nfc-manager", () => { + return { + NfcManager: { + start: jest.fn(), + stop: jest.fn(), + }, + } +}) + it("Receive", async () => { render( diff --git a/app/components/modal-nfc/modal-nfc.tsx b/app/components/modal-nfc/modal-nfc.tsx index 7ce43837c3..f4efb61204 100644 --- a/app/components/modal-nfc/modal-nfc.tsx +++ b/app/components/modal-nfc/modal-nfc.tsx @@ -9,22 +9,35 @@ import Icon from "react-native-vector-icons/Ionicons" import { GaloySecondaryButton } from "../atomic/galoy-secondary-button" import { parseDestination } from "@app/screens/send-bitcoin-screen/payment-destination" import { logParseDestinationResult } from "@app/utils/analytics" -import { useNavigation } from "@react-navigation/native" -import { StackNavigationProp } from "@react-navigation/stack" -import { RootStackParamList } from "@app/navigation/stack-param-lists" -import { DestinationDirection } from "@app/screens/send-bitcoin-screen/payment-destination/index.types" import { + DestinationDirection, + ReceiveDestination, +} from "@app/screens/send-bitcoin-screen/payment-destination/index.types" +import { + WalletCurrency, useAccountDefaultWalletLazyQuery, useScanningQrCodeScreenQuery, } from "@app/graphql/generated" import { useIsAuthed } from "@app/graphql/is-authed-context" import { LNURL_DOMAINS } from "@app/config" import { isIOS } from "@rneui/base" +import { + MoneyAmount, + WalletAmount, + toBtcMoneyAmount, + toUsdMoneyAmount, +} from "@app/types/amounts" +import { usePriceConversion } from "@app/hooks" export const ModalNfc: React.FC<{ isActive: boolean setIsActive: (arg: boolean) => void -}> = ({ isActive, setIsActive }) => { + settlementAmount?: WalletAmount + receiveViaNFC: ( + destination: ReceiveDestination, + settlementAmount: MoneyAmount<"BTC">, + ) => Promise +}> = ({ isActive, setIsActive, settlementAmount, receiveViaNFC }) => { const { data } = useScanningQrCodeScreenQuery({ skip: !useIsAuthed() }) const wallets = data?.me?.defaultAccount.wallets const bitcoinNetwork = data?.globals?.network @@ -33,10 +46,6 @@ export const ModalNfc: React.FC<{ fetchPolicy: "no-cache", }) - // FIXME: navigation destination? - const navigation = - useNavigation>() - const styles = useStyles() const { theme: { colors }, @@ -49,8 +58,23 @@ export const ModalNfc: React.FC<{ NfcManager.cancelTechnologyRequest() }, [setIsActive]) + const { convertMoneyAmount } = usePriceConversion() + React.useEffect(() => { - if (!LL || !wallets || !bitcoinNetwork || !isActive) { + if (isActive && !settlementAmount) { + Alert.alert(LL.ReceiveScreen.enterAmountFirst()) + setIsActive(false) + return + } + + if ( + !LL || + !wallets || + !bitcoinNetwork || + !isActive || + !receiveViaNFC || + !settlementAmount + ) { return } @@ -122,23 +146,22 @@ export const ModalNfc: React.FC<{ }) logParseDestinationResult(destination) - if (destination.valid) { + if (destination.valid && settlementAmount && convertMoneyAmount) { if (destination.destinationDirection === DestinationDirection.Send) { Alert.alert(LL.SettingsScreen.nfcOnlyReceive()) } else { - navigation.reset({ - routes: [ - { - name: "Primary", - }, - { - name: "redeemBitcoinDetail", - params: { - receiveDestination: destination, - }, - }, - ], - }) + let amount = settlementAmount.amount + if (settlementAmount.currency === WalletCurrency.Usd) { + amount = convertMoneyAmount( + toUsdMoneyAmount(settlementAmount.amount), + WalletCurrency.Btc, + ).amount + } + + destination.validDestination.minWithdrawable = amount * 1000 // coz msats + destination.validDestination.maxWithdrawable = amount * 1000 // coz msats + + receiveViaNFC(destination, toBtcMoneyAmount(settlementAmount.amount)) } } @@ -146,14 +169,19 @@ export const ModalNfc: React.FC<{ } init() + // Necessary because receiveViaNFC gets rerendered at useReceiveBitcoin + // And rerendering that shouldn't cause this useEffect to retrigger + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ LL, wallets, bitcoinNetwork, accountDefaultWalletQuery, - navigation, isActive, dismiss, + settlementAmount, + setIsActive, + convertMoneyAmount, ]) return ( diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index babf9a3c33..88cb5ce28e 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -2084,6 +2084,7 @@ const en: BaseTranslation = { title: "Home", }, ReceiveScreen: { + enterAmountFirst: "Please enter an amount first", activateNotifications: "Do you want to activate notifications to be notified when the payment has arrived?", copyClipboard: "Invoice has been copied in the clipboard", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index ec91cf395d..b46ee8226a 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -6468,6 +6468,10 @@ type RootTranslation = { title: string } ReceiveScreen: { + /** + * P​l​e​a​s​e​ ​e​n​t​e​r​ ​a​n​ ​a​m​o​u​n​t​ ​f​i​r​s​t + */ + enterAmountFirst: string /** * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​a​c​t​i​v​a​t​e​ ​n​o​t​i​f​i​c​a​t​i​o​n​s​ ​t​o​ ​b​e​ ​n​o​t​i​f​i​e​d​ ​w​h​e​n​ ​t​h​e​ ​p​a​y​m​e​n​t​ ​h​a​s​ ​a​r​r​i​v​e​d​? */ @@ -15085,6 +15089,10 @@ export type TranslationFunctions = { title: () => LocalizedString } ReceiveScreen: { + /** + * Please enter an amount first + */ + enterAmountFirst: () => LocalizedString /** * Do you want to activate notifications to be notified when the payment has arrived? */ diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index ebbd89f005..fc903d1da7 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -2055,6 +2055,7 @@ "title": "Home" }, "ReceiveScreen": { + "enterAmountFirst": "Please enter an amount first", "activateNotifications": "Do you want to activate notifications to be notified when the payment has arrived?", "copyClipboard": "Invoice has been copied in the clipboard", "copyClipboardBitcoin": "Bitcoin address has been copied in the clipboard", diff --git a/app/navigation/root-navigator.tsx b/app/navigation/root-navigator.tsx index bd800ca42e..b43d45ef95 100644 --- a/app/navigation/root-navigator.tsx +++ b/app/navigation/root-navigator.tsx @@ -81,21 +81,6 @@ import { } from "./stack-param-lists" import { NotificationSettingsScreen } from "@app/screens/settings-screen/notifications-screen" -const useStyles = makeStyles(({ colors }) => ({ - bottomNavigatorStyle: { - height: "10%", - paddingTop: 4, - backgroundColor: colors.white, - borderTopColor: colors.grey4, - }, - headerStyle: { - backgroundColor: colors.white, - }, - title: { - color: colors.black, - }, -})) - const RootNavigator = createStackNavigator() export const RootStack = () => { @@ -571,3 +556,18 @@ export const PrimaryNavigator = () => { ) } + +const useStyles = makeStyles(({ colors }) => ({ + bottomNavigatorStyle: { + height: "10%", + paddingTop: 4, + backgroundColor: colors.white, + borderTopColor: colors.grey4, + }, + headerStyle: { + backgroundColor: colors.white, + }, + title: { + color: colors.black, + }, +})) diff --git a/app/screens/receive-bitcoin-screen/receive-screen.tsx b/app/screens/receive-bitcoin-screen/receive-screen.tsx index fc20704e4d..5e0563a036 100644 --- a/app/screens/receive-bitcoin-screen/receive-screen.tsx +++ b/app/screens/receive-bitcoin-screen/receive-screen.tsx @@ -4,7 +4,7 @@ import { useIsAuthed } from "@app/graphql/is-authed-context" import { useI18nContext } from "@app/i18n/i18n-react" import { requestNotificationPermission } from "@app/utils/notifications" import { useIsFocused, useNavigation } from "@react-navigation/native" -import React, { useEffect } from "react" +import React, { useEffect, useState } from "react" import { TouchableOpacity, View } from "react-native" import { testProps } from "../../utils/testProps" import { withMyLnUpdateSub } from "./my-ln-updates-sub" @@ -18,6 +18,7 @@ import { NoteInput } from "@app/components/note-input" import Icon from "react-native-vector-icons/Ionicons" import { SetLightningAddressModal } from "@app/components/set-lightning-address-modal" import { GaloyCurrencyBubble } from "@app/components/atomic/galoy-currency-bubble" +import { ModalNfc } from "@app/components/modal-nfc" const ReceiveScreen = () => { const { @@ -32,6 +33,21 @@ const ReceiveScreen = () => { const request = useReceiveBitcoin() + const [displayReceiveNfc, setDisplayReceiveNfc] = useState(false) + + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + setDisplayReceiveNfc(true)} + > + + + ), + }) + }) + // notification permission useEffect(() => { let timeout: NodeJS.Timeout @@ -45,19 +61,6 @@ const ReceiveScreen = () => { return () => timeout && clearTimeout(timeout) }, [isAuthed, isFocused]) - useEffect(() => { - switch (request?.type) { - case Invoice.OnChain: - navigation.setOptions({ title: LL.ReceiveScreen.receiveViaOnchain() }) - break - case Invoice.Lightning: - navigation.setOptions({ title: LL.ReceiveScreen.receiveViaInvoice() }) - break - case Invoice.PayCode: - navigation.setOptions({ title: LL.ReceiveScreen.receiveViaPaycode() }) - } - }, [request?.type, LL.ReceiveScreen, navigation]) - useEffect(() => { if (request?.state === PaymentRequestState.Paid) { const id = setTimeout(() => navigation.goBack(), 5000) @@ -246,6 +249,13 @@ const ReceiveScreen = () => { isVisible={request.isSetLightningAddressModalVisible} toggleModal={request.toggleIsSetLightningAddressModalVisible} /> + + ) @@ -334,6 +344,11 @@ const useStyles = makeStyles(({ colors }) => ({ fontWeight: "700", }, btcLow: {}, + rotateIconHeaderRight: { + transform: [{ rotate: "90deg" }], + marginRight: 2, + padding: 8, + }, })) export default withMyLnUpdateSub(ReceiveScreen) diff --git a/app/screens/receive-bitcoin-screen/use-receive-bitcoin.ts b/app/screens/receive-bitcoin-screen/use-receive-bitcoin.ts index 0c2ec3ee54..1d2a52ad2b 100644 --- a/app/screens/receive-bitcoin-screen/use-receive-bitcoin.ts +++ b/app/screens/receive-bitcoin-screen/use-receive-bitcoin.ts @@ -29,11 +29,13 @@ import { useLnUpdateHashPaid } from "@app/graphql/ln-update-context" import { generateFutureLocalTime, secondsToH, secondsToHMS } from "./payment/helpers" import { toastShow } from "@app/utils/toast" import { useI18nContext } from "@app/i18n/i18n-react" +import fetch from "cross-fetch" import crashlytics from "@react-native-firebase/crashlytics" import { Alert, Share } from "react-native" import { TranslationFunctions } from "@app/i18n/i18n-types" import { BtcWalletDescriptor } from "@app/types/wallets" +import { ReceiveDestination } from "../send-bitcoin-screen/payment-destination/index.types" gql` query paymentRequest { @@ -455,6 +457,57 @@ export const useReceiveBitcoin = () => { readablePaymentRequest = `${pr.info.data.username}@${lnAddressHostname}` } + const receiveViaNFC = async ( + destination: ReceiveDestination, + settlementAmount: MoneyAmount<"BTC">, + ) => { + const { callback, defaultDescription, k1 } = destination.validDestination + const { data } = await lnInvoiceCreate({ + variables: { + input: { + walletId: prcd.receivingWalletDescriptor.id, + amount: settlementAmount.amount, + memo: prcd.memo || defaultDescription, + }, + }, + }) + + if (!data) { + Alert.alert(LL.RedeemBitcoinScreen.error()) + return + } + + const { + lnInvoiceCreate: { invoice, errors }, + } = data + + if ((errors && errors.length !== 0) || !invoice) { + console.error(errors, "error with lnInvoiceCreate") + Alert.alert(LL.RedeemBitcoinScreen.error()) + return + } + + const url = `${callback}${callback.includes("?") ? "&" : "?"}k1=${k1}&pr=${ + invoice.paymentRequest + }` + + const result = await fetch(url) + + if (result.ok) { + const lnurlResponse = await result.json() + if (lnurlResponse?.status?.toLowerCase() !== "ok") { + console.error(lnurlResponse, "error with redeeming") + Alert.alert(LL.RedeemBitcoinScreen.redeemingError()) + if (lnurlResponse?.reason) { + Alert.alert(lnurlResponse.reason) + } + } + } else { + console.error(result.text(), "error with submitting withdrawalRequest") + Alert.alert(LL.RedeemBitcoinScreen.submissionError()) + } + } + return { ...prcd, setType, @@ -472,5 +525,6 @@ export const useReceiveBitcoin = () => { isSetLightningAddressModalVisible, toggleIsSetLightningAddressModalVisible, readablePaymentRequest, + receiveViaNFC, } } diff --git a/app/screens/settings-screen/settings-screen.tsx b/app/screens/settings-screen/settings-screen.tsx index 9ee53b63c9..3f96e6eade 100644 --- a/app/screens/settings-screen/settings-screen.tsx +++ b/app/screens/settings-screen/settings-screen.tsx @@ -13,7 +13,6 @@ import ContactModal, { import crashlytics from "@react-native-firebase/crashlytics" import { gql } from "@apollo/client" -import { ModalNfc } from "@app/components/modal-nfc" import { ratingOptions } from "@app/config" import { useSettingsScreenQuery, @@ -162,8 +161,6 @@ export const SettingsScreen: React.FC = () => { setIsSetLightningAddressModalVisible(!isSetLightningAddressModalVisible) } - const [isNFCActive, setIsNFCActive] = React.useState(false) - const rateUs = () => { Rate.rate(ratingOptions, (success, errorMessage) => { if (success) { @@ -243,14 +240,6 @@ export const SettingsScreen: React.FC = () => { enabled: isAtLeastLevelZero && Boolean(lightningAddress), greyed: !isAtLeastLevelZero || !lightningAddress, }, - { - category: `${LL.SettingsScreen.nfc()} - beta`, - icon: "radio-outline", - id: "nfc", - action: () => setIsNFCActive(true), - enabled: isAtLeastLevelZero, - greyed: !isAtLeastLevelZero, - }, { category: LL.common.language(), icon: "ios-language", @@ -370,7 +359,6 @@ export const SettingsScreen: React.FC = () => { isVisible={isSetLightningAddressModalVisible} toggleModal={toggleIsSetLightningAddressModalVisible} /> - ) }