diff --git a/app/(app)/receive/alby-account.js b/app/(app)/receive/alby-account.js new file mode 100644 index 00000000..1f98ad4c --- /dev/null +++ b/app/(app)/receive/alby-account.js @@ -0,0 +1,5 @@ +import { AlbyAccount } from "../../../pages/receive/AlbyAccount"; + +export default function Page() { + return ; +} diff --git a/app/(app)/receive/invoice.js b/app/(app)/receive/invoice.js new file mode 100644 index 00000000..86451448 --- /dev/null +++ b/app/(app)/receive/invoice.js @@ -0,0 +1,5 @@ +import { Invoice } from "../../../pages/receive/Invoice"; + +export default function Page() { + return ; +} diff --git a/app/(app)/receive/lightning-address.js b/app/(app)/receive/lightning-address.js new file mode 100644 index 00000000..ec533ff2 --- /dev/null +++ b/app/(app)/receive/lightning-address.js @@ -0,0 +1,5 @@ +import { LightningAddress } from "../../../pages/receive/LightningAddress"; + +export default function Page() { + return ; +} diff --git a/app/(app)/receive/withdraw.js b/app/(app)/receive/withdraw.js new file mode 100644 index 00000000..1412dff1 --- /dev/null +++ b/app/(app)/receive/withdraw.js @@ -0,0 +1,5 @@ +import { Withdraw } from "../../../pages/receive/Withdraw"; + +export default function Page() { + return ; +} diff --git a/app/(app)/withdraw/index.js b/app/(app)/withdraw/index.js deleted file mode 100644 index c5149757..00000000 --- a/app/(app)/withdraw/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import { Withdraw } from "../../../pages/withdraw/Withdraw"; - -export default function Page() { - return ; -} diff --git a/assets/alby-account.png b/assets/alby-account.png new file mode 100644 index 00000000..bb890a74 Binary files /dev/null and b/assets/alby-account.png differ diff --git a/components/CreateInvoice.tsx b/components/CreateInvoice.tsx new file mode 100644 index 00000000..b11faf92 --- /dev/null +++ b/components/CreateInvoice.tsx @@ -0,0 +1,218 @@ +import { Nip47Transaction } from "@getalby/sdk/dist/NWCClient"; +import * as Clipboard from "expo-clipboard"; +import { router } from "expo-router"; +import React from "react"; +import { Image, Share, View } from "react-native"; +import Toast from "react-native-toast-message"; +import DismissableKeyboardView from "~/components/DismissableKeyboardView"; +import { DualCurrencyInput } from "~/components/DualCurrencyInput"; +import { CopyIcon, ShareIcon } from "~/components/Icons"; +import Loading from "~/components/Loading"; +import QRCode from "~/components/QRCode"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Text } from "~/components/ui/text"; +import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; +import { errorToast } from "~/lib/errorToast"; +import { useAppStore } from "~/lib/state/appStore"; + +export function CreateInvoice() { + const getFiatAmount = useGetFiatAmount(); + const [isLoading, setLoading] = React.useState(false); + const [invoice, setInvoice] = React.useState(""); + const [amount, setAmount] = React.useState(""); + const [comment, setComment] = React.useState(""); + + function generateInvoice(amount?: number) { + if (!amount) { + errorToast(new Error("0-amount invoices are currently not supported")); + return; + } + (async () => { + setLoading(true); + try { + const nwcClient = useAppStore.getState().nwcClient; + if (!nwcClient) { + throw new Error("NWC client not connected"); + } + const response = await nwcClient.makeInvoice({ + amount: amount * 1000 /*FIXME: allow 0-amount invoices */, + ...(comment ? { description: comment } : {}), + }); + + console.info("makeInvoice Response", response); + + setInvoice(response.invoice); + } catch (error) { + console.error(error); + errorToast(error); + } + setLoading(false); + })(); + } + + function copy() { + const text = invoice; + if (!text) { + errorToast(new Error("Nothing to copy")); + return; + } + Clipboard.setStringAsync(text); + Toast.show({ + type: "success", + text1: "Copied to clipboard", + }); + } + + async function share() { + const message = invoice; + try { + if (!message) { + throw new Error("no lightning address set"); + } + await Share.share({ + message, + }); + } catch (error) { + console.error("Error sharing:", error); + errorToast(error); + } + } + + React.useEffect(() => { + let polling = true; + let pollCount = 0; + let prevTransaction: Nip47Transaction | undefined; + (async () => { + while (polling) { + try { + const transactions = await useAppStore + .getState() + .nwcClient?.listTransactions({ + limit: 1, + type: "incoming", + }); + const receivedTransaction = transactions?.transactions[0]; + if (receivedTransaction) { + if ( + polling && + pollCount > 0 && + receivedTransaction.payment_hash !== prevTransaction?.payment_hash + ) { + if (invoice && receivedTransaction.invoice === invoice) { + router.dismissAll(); + router.navigate({ + pathname: "/receive/success", + params: { invoice: receivedTransaction.invoice }, + }); + } else { + console.info("Received another payment"); + } + } + prevTransaction = receivedTransaction; + } + ++pollCount; + } catch (error) { + console.error("Failed to poll for incoming transaction", error); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + })(); + return () => { + polling = false; + }; + }, [invoice]); + + return ( + <> + {invoice ? ( + <> + + + + + + + + + + + {new Intl.NumberFormat().format(+amount)}{" "} + + + sats + + + {getFiatAmount && ( + + {getFiatAmount(+amount)} + + )} + + + + Waiting for payment + + + + + + + + ) : ( + + + + + + + Description (optional) + + + + + + + + + + )} + + ); +} diff --git a/components/Icons.tsx b/components/Icons.tsx index c6907390..89d8100c 100644 --- a/components/Icons.tsx +++ b/components/Icons.tsx @@ -1,4 +1,5 @@ import { + PopiconsAtSymbolSolid as AddressIcon, PopiconsCircleExclamationLine as AlertCircleIcon, PopiconsBitcoinSolid as BitcoinIcon, PopiconsAddressBookSolid as BookUserIcon, @@ -10,13 +11,16 @@ import { PopiconsUploadSolid as ExportIcon, PopiconsTouchIdSolid as FingerprintIcon, PopiconsCircleInfoLine as HelpCircleIcon, + PopiconsLinkExternalSolid as LinkIcon, PopiconsArrowDownLine as MoveDownIcon, PopiconsArrowUpLine as MoveUpIcon, PopiconsNotificationSquareSolid as NotificationIcon, PopiconsLifebuoySolid as OnboardingIcon, PopiconsClipboardTextSolid as PasteIcon, + PopiconsQrCodeMinimalSolid as QRIcon, PopiconsReloadLine as RefreshIcon, PopiconsReloadSolid as ResetIcon, + PopiconsFullscreenSolid as ScanIcon, PopiconsSettingsMinimalSolid as SettingsIcon, PopiconsShareSolid as ShareIcon, PopiconsLogoutSolid as SignOutIcon, @@ -45,6 +49,7 @@ function interopIcon(icon: React.FunctionComponent) { }); } +interopIcon(AddressIcon); interopIcon(AlertCircleIcon); interopIcon(BitcoinIcon); interopIcon(BookUserIcon); @@ -56,13 +61,16 @@ interopIcon(EditIcon); interopIcon(ExportIcon); interopIcon(FingerprintIcon); interopIcon(HelpCircleIcon); +interopIcon(LinkIcon); interopIcon(MoveDownIcon); interopIcon(MoveUpIcon); interopIcon(NotificationIcon); interopIcon(OnboardingIcon); interopIcon(PasteIcon); +interopIcon(QRIcon); interopIcon(RefreshIcon); interopIcon(ResetIcon); +interopIcon(ScanIcon); interopIcon(SettingsIcon); interopIcon(ShareIcon); interopIcon(SignOutIcon); @@ -77,6 +85,7 @@ interopIcon(XIcon); interopIcon(ZapIcon); export { + AddressIcon, AlertCircleIcon, BitcoinIcon, BookUserIcon, @@ -88,13 +97,16 @@ export { ExportIcon, FingerprintIcon, HelpCircleIcon, + LinkIcon, MoveDownIcon, MoveUpIcon, NotificationIcon, OnboardingIcon, PasteIcon, + QRIcon, RefreshIcon, ResetIcon, + ScanIcon, SettingsIcon, ShareIcon, SignOutIcon, diff --git a/package.json b/package.json index 12560abd..aba14060 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "@noble/curves": "^1.6.0", "@popicons/react-native": "^0.0.22", "@react-native-async-storage/async-storage": "1.23.1", - "@react-navigation/native-stack": "^7.2.0", "@rn-primitives/dialog": "^1.0.3", "@rn-primitives/portal": "^1.0.3", "@rn-primitives/switch": "^1.0.3", diff --git a/pages/receive/AlbyAccount.tsx b/pages/receive/AlbyAccount.tsx new file mode 100644 index 00000000..12a99815 --- /dev/null +++ b/pages/receive/AlbyAccount.tsx @@ -0,0 +1,60 @@ +import { openURL } from "expo-linking"; +import React from "react"; +import { Dimensions, Image, ScrollView, View } from "react-native"; +import { LinkIcon } from "~/components/Icons"; +import Screen from "~/components/Screen"; +import { Button } from "~/components/ui/button"; +import { Text } from "~/components/ui/text"; + +export function AlbyAccount() { + const dimensions = Dimensions.get("window"); + const imageWidth = Math.round((dimensions.width * 3) / 5); + + return ( + + + + + + + Get your lightning address with Alby Account + + + + {"\u2022 "}Lightning address & Nostr identifier, + + + {"\u2022 "}Personal tipping page, + + + {"\u2022 "}Access to podcasting 2.0 apps, + + + {"\u2022 "}Buy bitcoin directly to your wallet, + + + {"\u2022 "}Useful email wallet notifications, + + + {"\u2022 "}Priority support. + + + + + + + + + ); +} diff --git a/pages/receive/Invoice.tsx b/pages/receive/Invoice.tsx new file mode 100644 index 00000000..edb2f7ff --- /dev/null +++ b/pages/receive/Invoice.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { CreateInvoice } from "~/components/CreateInvoice"; +import Screen from "~/components/Screen"; + +export function Invoice() { + return ( + <> + + + + ); +} diff --git a/pages/receive/LightningAddress.tsx b/pages/receive/LightningAddress.tsx new file mode 100644 index 00000000..93d7df68 --- /dev/null +++ b/pages/receive/LightningAddress.tsx @@ -0,0 +1,43 @@ +import { router } from "expo-router"; +import React from "react"; +import { View } from "react-native"; +import { QRIcon } from "~/components/Icons"; +import Screen from "~/components/Screen"; +import { Button } from "~/components/ui/button"; +import { Text } from "~/components/ui/text"; +import { useAppStore } from "~/lib/state/appStore"; + +export function LightningAddress() { + const walletId = useAppStore((store) => store.selectedWalletId); + + return ( + + + + + + satoshi + + @getalby.com + + + + Attach your lightning address to this wallet to display it as QR code + for fast face-to-face transactions + + + + + + ); +} diff --git a/pages/receive/Receive.tsx b/pages/receive/Receive.tsx index cbe2303d..829d3b1a 100644 --- a/pages/receive/Receive.tsx +++ b/pages/receive/Receive.tsx @@ -1,71 +1,30 @@ -import { Nip47Transaction } from "@getalby/sdk/dist/NWCClient"; import * as Clipboard from "expo-clipboard"; import { router } from "expo-router"; import React from "react"; import { Share, TouchableOpacity, View } from "react-native"; import Toast from "react-native-toast-message"; -import DismissableKeyboardView from "~/components/DismissableKeyboardView"; -import { DualCurrencyInput } from "~/components/DualCurrencyInput"; -import { CopyIcon, ShareIcon, WithdrawIcon, ZapIcon } from "~/components/Icons"; -import Loading from "~/components/Loading"; +import { CreateInvoice } from "~/components/CreateInvoice"; +import { + AddressIcon, + ScanIcon, + ShareIcon, + WithdrawIcon, + ZapIcon, +} from "~/components/Icons"; import QRCode from "~/components/QRCode"; import Screen from "~/components/Screen"; import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; import { Text } from "~/components/ui/text"; -import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; import { errorToast } from "~/lib/errorToast"; import { useAppStore } from "~/lib/state/appStore"; export function Receive() { - const getFiatAmount = useGetFiatAmount(); - const [isLoading, setLoading] = React.useState(false); - const [invoice, _setInvoice] = React.useState(""); - const invoiceRef = React.useRef(""); - const [amount, setAmount] = React.useState(""); - const [comment, setComment] = React.useState(""); - const [enterCustomAmount, setEnterCustomAmount] = React.useState(false); const selectedWalletId = useAppStore((store) => store.selectedWalletId); const wallets = useAppStore((store) => store.wallets); const lightningAddress = wallets[selectedWalletId].lightningAddress; - const nwcCapabilities = wallets[selectedWalletId].nwcCapabilities; - - function setInvoice(invoice: string) { - _setInvoice(invoice); - invoiceRef.current = invoice; - } - - function generateInvoice(amount?: number) { - if (!amount) { - console.error("0-amount invoices are currently not supported"); - return; - } - (async () => { - setLoading(true); - try { - const nwcClient = useAppStore.getState().nwcClient; - if (!nwcClient) { - throw new Error("NWC client not connected"); - } - const response = await nwcClient.makeInvoice({ - amount: amount * 1000 /*FIXME: allow 0-amount invoices */, - ...(comment ? { description: comment } : {}), - }); - - console.info("makeInvoice Response", response); - - setInvoice(response.invoice); - setEnterCustomAmount(false); - } catch (error) { - console.error(error); - errorToast(error); - } - setLoading(false); - })(); - } function copy() { - const text = invoice || lightningAddress; + const text = lightningAddress; if (!text) { errorToast(new Error("Nothing to copy")); return; @@ -77,92 +36,11 @@ export function Receive() { }); } - // TODO: move this somewhere else to have app-wide notifications of incoming payments - React.useEffect(() => { - if (!nwcCapabilities || nwcCapabilities.indexOf("notifications") < 0) { - // TODO: we do not check if the wallet supports listTransactions, - // and could also use lookupInvoice if it's a custom invoice - let polling = true; - let pollCount = 0; - let prevTransaction: Nip47Transaction | undefined; - (async () => { - while (polling) { - try { - const transactions = await useAppStore - .getState() - .nwcClient?.listTransactions({ - limit: 1, - type: "incoming", - }); - const receivedTransaction = transactions?.transactions[0]; - if (receivedTransaction) { - if ( - polling && - pollCount > 0 && - receivedTransaction.payment_hash !== - prevTransaction?.payment_hash - ) { - if ( - !invoiceRef.current || - receivedTransaction.invoice === invoiceRef.current - ) { - router.dismissAll(); - router.navigate({ - pathname: "/receive/success", - params: { invoice: receivedTransaction.invoice }, - }); - } else { - console.info("Received another payment"); - } - } - prevTransaction = receivedTransaction; - } - ++pollCount; - } catch (error) { - console.error("Failed to list transactions", error); - } - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - })(); - return () => { - polling = false; - }; - } - - const nwcClient = useAppStore.getState().nwcClient; - if (!nwcClient) { - throw new Error("NWC client not connected"); - } - let unsub: (() => void) | undefined = undefined; - (async () => { - unsub = await nwcClient.subscribeNotifications((notification) => { - console.info("RECEIVED notification", notification); - if (notification.notification_type === "payment_received") { - if ( - !invoiceRef.current || - notification.notification.invoice === invoiceRef.current - ) { - router.dismissAll(); - router.navigate({ - pathname: "/receive/success", - params: { invoice: notification.notification.invoice }, - }); - } else { - console.info("Received another payment"); - } - } - }); - })(); - return () => { - unsub?.(); - }; - }, [nwcCapabilities]); - async function share() { - const message = invoice || lightningAddress; + const message = lightningAddress; try { if (!message) { - throw new Error("no invoice or lightning address set"); + throw new Error("no lightning address set"); } await Share.share({ message, @@ -175,164 +53,87 @@ export function Receive() { return ( <> - - {!enterCustomAmount && !invoice && !lightningAddress && ( + + !lightningAddress && ( + <> + { + router.push("/receive/lightning-address"); + }} + > + + + { + router.push("receive/withdraw"); + }} + > + + + + ) + } + /> + {!lightningAddress ? ( + + ) : ( <> - - {/* TODO: re-add when we have a way to create a lightning address for new users */} - {/* - - Receive quickly with a Lightning Address - - - - */} + + + + + + {lightningAddress} + + + + - - - )} - {!enterCustomAmount && (invoice.length || lightningAddress) && ( - <> - - - - {invoice ? ( - - - {new Intl.NumberFormat().format(+amount)}{" "} - - - sats - - - ) : ( - lightningAddress && ( - - - {lightningAddress} - - - ) - )} - {invoice && getFiatAmount && ( - - {getFiatAmount(+amount)} - - )} - - {invoice && ( - - - Waiting for payment - - )} - - - - {!enterCustomAmount && invoice && ( - - )} - {!enterCustomAmount && !invoice && ( - - )} - {!enterCustomAmount && !invoice && ( - - )} )} - {/* TODO: move to one place - this is all copied from LNURL-Pay */} - {!invoice && enterCustomAmount && ( - - - - - - - Description (optional) - - - - - - - - - - )} ); } diff --git a/pages/receive/ReceiveSuccess.tsx b/pages/receive/ReceiveSuccess.tsx index 6c43f0ac..f3c9b924 100644 --- a/pages/receive/ReceiveSuccess.tsx +++ b/pages/receive/ReceiveSuccess.tsx @@ -16,7 +16,7 @@ export function ReceiveSuccess() { }); return ( - + diff --git a/pages/withdraw/Withdraw.tsx b/pages/receive/Withdraw.tsx similarity index 99% rename from pages/withdraw/Withdraw.tsx rename to pages/receive/Withdraw.tsx index 31f7f536..830acbe4 100644 --- a/pages/withdraw/Withdraw.tsx +++ b/pages/receive/Withdraw.tsx @@ -160,7 +160,7 @@ export function Withdraw() { return ( <> - + {isLoading && ( diff --git a/pages/settings/wallets/EditWallet.tsx b/pages/settings/wallets/EditWallet.tsx index 9773a4c8..65febc93 100644 --- a/pages/settings/wallets/EditWallet.tsx +++ b/pages/settings/wallets/EditWallet.tsx @@ -5,11 +5,11 @@ import { Pressable, Alert as RNAlert, View } from "react-native"; import Toast from "react-native-toast-message"; import Alert from "~/components/Alert"; import { + AddressIcon, ExportIcon, TrashIcon, TriangleAlertIcon, WalletIcon, - ZapIcon, } from "~/components/Icons"; import Loading from "~/components/Loading"; import Screen from "~/components/Screen"; @@ -105,7 +105,7 @@ export function EditWallet() { - + Lightning Address diff --git a/pages/settings/wallets/LightningAddress.tsx b/pages/settings/wallets/LightningAddress.tsx index 3832af2d..be453818 100644 --- a/pages/settings/wallets/LightningAddress.tsx +++ b/pages/settings/wallets/LightningAddress.tsx @@ -1,15 +1,15 @@ -import { LightningAddress } from "@getalby/lightning-tools"; import { router, useLocalSearchParams } from "expo-router"; import React from "react"; import { View } from "react-native"; import Toast from "react-native-toast-message"; +import Alert from "~/components/Alert"; import DismissableKeyboardView from "~/components/DismissableKeyboardView"; +import { AlertCircleIcon } from "~/components/Icons"; import Loading from "~/components/Loading"; import Screen from "~/components/Screen"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Text } from "~/components/ui/text"; -import { errorToast } from "~/lib/errorToast"; import { useAppStore } from "~/lib/state/appStore"; export function SetLightningAddress() { @@ -24,51 +24,27 @@ export function SetLightningAddress() { const updateLightningAddress = async () => { setLoading(true); - try { - if (lightningAddress) { - const nwcClient = useAppStore.getState().getNWCClient(walletId); - if (!nwcClient) { - throw new Error("NWC client not connected"); - } - - // by generating an invoice from the lightning address and checking - // we own it via lookup_invoice, we can prove we own the lightning address - const _lightningAddress = new LightningAddress(lightningAddress); - await _lightningAddress.fetch(); - const invoiceFromLightningAddress = - await _lightningAddress.requestInvoice({ satoshi: 1 }); - let found = false; - try { - const transaction = await nwcClient.lookupInvoice({ - payment_hash: invoiceFromLightningAddress.paymentHash, - }); - found = - transaction?.invoice === invoiceFromLightningAddress.paymentRequest; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_ /* transaction is not found */) {} - - if (!found) { - throw new Error( - "Could not verify you are the owner of this lightning address.", - ); - } - } - - useAppStore.getState().updateWallet({ lightningAddress }, walletId); - Toast.show({ - type: "success", - text1: "Lightning address updated", - }); - router.back(); - } catch (error) { - errorToast(error); - } + useAppStore.getState().updateWallet({ lightningAddress }, walletId); + Toast.show({ + type: "success", + text1: "Lightning address updated", + }); + router.back(); setLoading(false); }; return ( + + + @@ -92,6 +68,7 @@ export function SetLightningAddress() {