diff --git a/app.json b/app.json index f6caee75..8e702764 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "Alby Go", "slug": "alby-mobile", "version": "1.7.2", - "scheme": ["lightning", "bitcoin", "alby"], + "scheme": ["lightning", "bitcoin", "alby", "nostr+walletconnect"], "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/app/_layout.tsx b/app/_layout.tsx index f6f86c02..be7150cf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,7 +10,7 @@ import { Slot, SplashScreen } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { swrConfiguration } from "lib/swr"; import * as React from "react"; -import { SafeAreaView } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import Toast from "react-native-toast-message"; import { SWRConfig } from "swr"; import { toastConfig } from "~/components/ToastConfig"; diff --git a/components/Alert.tsx b/components/Alert.tsx new file mode 100644 index 00000000..a85aceaa --- /dev/null +++ b/components/Alert.tsx @@ -0,0 +1,50 @@ +import { View } from "react-native"; +import { SvgProps } from "react-native-svg"; +import { + Card, + CardContent, + CardDescription, + CardTitle, +} from "~/components/ui/card"; +import { cn } from "~/lib/utils"; + +type Props = { + type: "error" | "warn" | "info"; + icon: React.FunctionComponent; + title: string; + description: string; + className?: string; +}; + +function Alert({ title, description, type, icon: Icon, className }: Props) { + const textColor = + type === "error" + ? "text-red-700 dark:text-red-300" + : type === "warn" + ? "text-orange-700 dark:text-orange-300" + : "text-blue-700 dark:text-blue-300"; + return ( + + + + + {title} + {description} + + + + ); +} + +export default Alert; diff --git a/components/DualCurrencyInput.tsx b/components/DualCurrencyInput.tsx index 8b1d710b..c666a16e 100644 --- a/components/DualCurrencyInput.tsx +++ b/components/DualCurrencyInput.tsx @@ -9,6 +9,7 @@ import { SATS_REGEX, } from "~/lib/constants"; import { useAppStore } from "~/lib/state/appStore"; +import { cn } from "~/lib/utils"; import { Input } from "./ui/input"; import { Text } from "./ui/text"; @@ -17,6 +18,8 @@ type DualCurrencyInputProps = { setAmount(amount: string): void; autoFocus?: boolean; readOnly?: boolean; + max?: number; + min?: number; }; export function DualCurrencyInput({ @@ -24,6 +27,8 @@ export function DualCurrencyInput({ setAmount, autoFocus = false, readOnly = false, + max, + min, }: DualCurrencyInputProps) { const getFiatAmount = useGetFiatAmount(); const getSatsAmount = useGetSatsAmount(); @@ -61,7 +66,11 @@ export function DualCurrencyInput({ return ( max) || (min && Number(amount) < min)) && + "text-destructive", + )} placeholder="0" keyboardType={inputMode === "sats" ? "number-pad" : "decimal-pad"} value={inputMode === "sats" ? amount : fiatAmount} diff --git a/components/icons/FailedTransaction.tsx b/components/icons/FailedTransaction.tsx new file mode 100644 index 00000000..03d671ac --- /dev/null +++ b/components/icons/FailedTransaction.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { useColorScheme } from "react-native"; +import Svg, { Path, Rect, SvgProps } from "react-native-svg"; + +const FailedTransactionIcon = (props: SvgProps) => { + const colorScheme = useColorScheme(); + + const colors = { + light: { + rectFill: "#FEE2E2", + pathStroke: "#FB7185", + }, + dark: { + rectFill: "#450A0A", + pathStroke: "#DC2626", + }, + }; + + const currentColors = colorScheme === "dark" ? colors.dark : colors.light; + + return ( + + + + + ); +}; + +export default FailedTransactionIcon; diff --git a/components/icons/ReceivedTransaction.tsx b/components/icons/ReceivedTransaction.tsx new file mode 100644 index 00000000..4f0fd23c --- /dev/null +++ b/components/icons/ReceivedTransaction.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { useColorScheme } from "react-native"; +import Svg, { Path, Rect, SvgProps } from "react-native-svg"; + +const ReceivedTransactionIcon = (props: SvgProps) => { + const colorScheme = useColorScheme(); + + const colors = { + light: { + rectFill: "#DBFBE6", + pathStroke: "#22C45E", + }, + dark: { + rectFill: "#022C22", + pathStroke: "#10B981", + }, + }; + + const currentColors = colorScheme === "dark" ? colors.dark : colors.light; + + return ( + + + + + ); +}; + +export default ReceivedTransactionIcon; diff --git a/components/icons/SentTransaction.tsx b/components/icons/SentTransaction.tsx new file mode 100644 index 00000000..21d4efbf --- /dev/null +++ b/components/icons/SentTransaction.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { useColorScheme } from "react-native"; +import Svg, { Path, Rect, SvgProps } from "react-native-svg"; + +const SentTransactionIcon = (props: SvgProps) => { + const colorScheme = useColorScheme(); + + const colors = { + light: { + rectFill: "#FEECD4", + pathStroke: "#FA913C", + }, + dark: { + rectFill: "#431407", + pathStroke: "#D97706", + }, + }; + + const currentColors = colorScheme === "dark" ? colors.dark : colors.light; + + return ( + + + + + ); +}; + +export default SentTransactionIcon; diff --git a/components/ui/card.tsx b/components/ui/card.tsx index b657d661..87cb6a46 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -56,7 +56,7 @@ const CardDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/lib/link.ts b/lib/link.ts index aa5db1fd..5dfcb053 100644 --- a/lib/link.ts +++ b/lib/link.ts @@ -2,7 +2,12 @@ import { router } from "expo-router"; import { BOLT11_REGEX } from "./constants"; import { lnurl as lnurlLib } from "./lnurl"; -const SUPPORTED_SCHEMES = ["lightning:", "bitcoin:", "alby:"]; +const SUPPORTED_SCHEMES = [ + "lightning:", + "bitcoin:", + "alby:", + "nostr+walletconnect:", +]; // Register exp scheme for testing during development // https://docs.expo.dev/guides/linking/#creating-urls @@ -22,6 +27,19 @@ export const handleLink = async (url: string) => { if (SUPPORTED_SCHEMES.indexOf(parsedUrl.protocol) > -1) { let { username, hostname, protocol, pathname, search } = parsedUrl; + if (parsedUrl.protocol === "nostr+walletconnect:") { + if (router.canDismiss()) { + router.dismissAll(); + } + console.info("Navigating to wallet setup"); + router.push({ + pathname: "/settings/wallets/setup", + params: { + nwcUrl: protocol + hostname + search, + }, + }); + return; + } if (parsedUrl.protocol === "exp:") { if (!parsedUrl.pathname) { diff --git a/package.json b/package.json index eab8a4cf..4137c40d 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "react-native-get-random-values": "^1.9.0", "react-native-qrcode-svg": "^6.3.1", "react-native-reanimated": "~3.16.1", - "react-native-safe-area-context": "4.12.0", + "react-native-safe-area-context": "5.0.0", "react-native-screens": "~4.1.0", "react-native-svg": "15.8.0", "react-native-toast-message": "^2.2.0", diff --git a/pages/Transaction.tsx b/pages/Transaction.tsx index 9bad76f2..ee9918e9 100644 --- a/pages/Transaction.tsx +++ b/pages/Transaction.tsx @@ -6,7 +6,9 @@ import { useLocalSearchParams } from "expo-router"; import React from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import Toast from "react-native-toast-message"; -import { MoveDownIcon, MoveUpIcon, XIcon } from "~/components/Icons"; +import FailedTransactionIcon from "~/components/icons/FailedTransaction"; +import ReceivedTransactionIcon from "~/components/icons/ReceivedTransaction"; +import SentTransactionIcon from "~/components/icons/SentTransaction"; import Screen from "~/components/Screen"; import { Text } from "~/components/ui/text"; import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; @@ -22,11 +24,11 @@ type Boostagram = { name: string; podcast: string; url: string; - episode?: string; - itemID?: string; - ts?: string; + episode?: string | number; + itemID?: string | number; + ts?: string | number; message?: string; - sender_id: string; + sender_id: string | number; sender_name: string; time: string; action: string; @@ -64,7 +66,7 @@ export function Transaction() { {transaction.type === "incoming" && ( - + )} {transaction.type === "outgoing" && ( - + )} )} {transaction.state === "failed" && ( - + )} @@ -105,7 +103,7 @@ export function Transaction() { - + {" "} sats {getFiatAmount && ( - + {getFiatAmount(Math.floor(transaction.amount / 1000))} )} - + - {props.title} + {props.title} {props.copy ? ( - {props.content} + + {props.content} + ) : ( - + {props.content} )} @@ -204,40 +204,30 @@ function TransactionDetailRow(props: { } function PodcastingInfo({ boost }: { boost: Boostagram }) { + const renderDetail = (title: string, content: any) => { + if (content === 0 || !!content) { + return ; + } + return null; + }; return ( <> - {boost.message && ( - - )} - {boost.podcast && ( - - )} - {boost.episode && ( - - )} - {boost.action && ( - - )} - {boost.ts && ( - - )} - {boost.value_msat_total && ( - - )} - {boost.sender_name && ( - - )} - {boost.app_name && ( - + {renderDetail("Message", boost.message)} + {renderDetail("Podcast", boost.podcast)} + {renderDetail("Episode", boost.episode)} + {renderDetail("Action", boost.action)} + {renderDetail("Timestamp", boost.ts)} + {renderDetail( + "Total amount", + boost.value_msat_total + ? Math.floor(boost.value_msat_total / 1000) + + (Math.floor(boost.value_msat_total / 1000) === 1 + ? " sat" + : " sats") + : null, )} + {renderDetail("Sender", boost.sender_name)} + {renderDetail("App", boost.app_name)} ); } - -export default PodcastingInfo; diff --git a/pages/Transactions.tsx b/pages/Transactions.tsx index f73d07ee..a8c06c36 100644 --- a/pages/Transactions.tsx +++ b/pages/Transactions.tsx @@ -9,7 +9,10 @@ import { ScrollView, View, } from "react-native"; -import { MoveDownIcon, MoveUpIcon, XIcon } from "~/components/Icons"; +import { XIcon } from "~/components/Icons"; +import FailedTransactionIcon from "~/components/icons/FailedTransaction"; +import ReceivedTransactionIcon from "~/components/icons/ReceivedTransaction"; +import SentTransactionIcon from "~/components/icons/SentTransaction"; import Screen from "~/components/Screen"; import { Button } from "~/components/ui/button"; import { Skeleton } from "~/components/ui/skeleton"; @@ -118,7 +121,7 @@ export function Transactions() { > @@ -126,24 +129,18 @@ export function Transactions() { {transaction.state !== "failed" && ( <> {transaction.type === "incoming" && ( - + )} {transaction.type === "outgoing" && ( - + )} )} - {transaction.state === "failed" && ( - - )} + {transaction.state === "failed" && } - + {transaction.type === "incoming" ? "Received" : transaction.state === "failed" @@ -165,7 +162,7 @@ export function Transactions() { {transaction.type === "incoming" ? "+" : "-"}{" "} {Math.floor(transaction.amount / 1000)} - sats + sats {getFiatAmount && diff --git a/pages/send/ConfirmPayment.tsx b/pages/send/ConfirmPayment.tsx index e4c2a242..12b2fbba 100644 --- a/pages/send/ConfirmPayment.tsx +++ b/pages/send/ConfirmPayment.tsx @@ -2,18 +2,13 @@ import { Invoice } from "@getalby/lightning-tools"; import { Link, router, useLocalSearchParams } from "expo-router"; import React from "react"; -import { View } from "react-native"; -import { TriangleAlert, ZapIcon } from "~/components/Icons"; +import { Pressable, View } from "react-native"; +import Alert from "~/components/Alert"; +import { AlertCircleIcon, ZapIcon } from "~/components/Icons"; import Loading from "~/components/Loading"; import { Receiver } from "~/components/Receiver"; import Screen from "~/components/Screen"; import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardTitle, -} from "~/components/ui/card"; import { Text } from "~/components/ui/text"; import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; import { useTransactions } from "~/hooks/useTransactions"; @@ -119,19 +114,15 @@ export function ConfirmPayment() { {!transactions?.transactions.some( (transaction) => transaction.state === "pending", ) && ( - - - - - - One or more pending payments - - Please check your transaction list before paying to ensure - you do not make a payment twice. - - - - + + + + )} - + return ( + { + if (item.index !== selectedWalletId) { + useAppStore.getState().setSelectedWalletId(item.index); + router.dismissAll(); + router.navigate("/"); + Toast.show({ + type: "success", + text1: `Switched wallet to ${item.item.name || DEFAULT_WALLET_NAME}`, + position: "top", + }); + } + }} + className={cn( + "flex flex-row items-center justify-between p-6 rounded-2xl border-2", + active ? "border-primary" : "border-transparent", + )} + > + + + + {item.item.name || DEFAULT_WALLET_NAME} + + + {active && ( + + + + + + )} + + ); + }} + /> - + + + + ); } diff --git a/pages/settings/wallets/EditWallet.tsx b/pages/settings/wallets/EditWallet.tsx index 8b17624f..c725f0be 100644 --- a/pages/settings/wallets/EditWallet.tsx +++ b/pages/settings/wallets/EditWallet.tsx @@ -1,9 +1,9 @@ import { Link, router } from "expo-router"; -import { Alert, Pressable, Text, View } from "react-native"; +import { Pressable, Alert as RNAlert, View } from "react-native"; import Toast from "react-native-toast-message"; -import { Nip47Capability } from "@getalby/sdk/dist/NWCClient"; import * as Clipboard from "expo-clipboard"; +import Alert from "~/components/Alert"; import { ExportIcon, TrashIcon, @@ -18,29 +18,31 @@ import { CardDescription, CardTitle, } from "~/components/ui/card"; -import { DEFAULT_WALLET_NAME } from "~/lib/constants"; +import { DEFAULT_WALLET_NAME, REQUIRED_CAPABILITIES } from "~/lib/constants"; import { useAppStore } from "~/lib/state/appStore"; export function EditWallet() { const selectedWalletId = useAppStore((store) => store.selectedWalletId); const wallets = useAppStore((store) => store.wallets); return ( - + - {(["notifications", "list_transactions"] as Nip47Capability[]).map( - (capability) => - (wallets[selectedWalletId].nwcCapabilities || []).indexOf( - capability, - ) < 0 && ( - - - - - Your wallet does not support {capability} - - - - ), + {/* TODO: Do not allow notifications to be toggled without notifications capability */} + {!REQUIRED_CAPABILITIES.every((capability) => + (wallets[selectedWalletId].nwcCapabilities || []).includes(capability), + ) && ( + + !(wallets[selectedWalletId].nwcCapabilities || []).includes( + capability, + ), + ).join(", ")}`} + icon={TriangleAlertIcon} + className="mb-0" + /> )} @@ -77,7 +79,7 @@ export function EditWallet() { { - Alert.alert( + RNAlert.alert( "Export Wallet", "Your Wallet Connection Secret will be copied to the clipboard which you can add to another app. For per-app permission management, try out Alby Hub or add your Wallet Connection Secret to an Alby Account.", [ @@ -121,7 +123,7 @@ export function EditWallet() { { - Alert.alert( + RNAlert.alert( "Delete Wallet", "Are you sure you want to delete your wallet? This cannot be undone.", [ diff --git a/pages/settings/wallets/SetupWallet.tsx b/pages/settings/wallets/SetupWallet.tsx index 97518e1c..fde21111 100644 --- a/pages/settings/wallets/SetupWallet.tsx +++ b/pages/settings/wallets/SetupWallet.tsx @@ -1,13 +1,19 @@ import { nwc } from "@getalby/sdk"; import { Nip47Capability } from "@getalby/sdk/dist/NWCClient"; import * as Clipboard from "expo-clipboard"; -import { router } from "expo-router"; +import { router, useLocalSearchParams } from "expo-router"; import { useAppStore } from "lib/state/appStore"; import React from "react"; import { Pressable, TouchableOpacity, View } from "react-native"; import Toast from "react-native-toast-message"; +import Alert from "~/components/Alert"; import DismissableKeyboardView from "~/components/DismissableKeyboardView"; -import { HelpCircleIcon, PasteIcon, XIcon } from "~/components/Icons"; +import { + HelpCircleIcon, + PasteIcon, + TriangleAlertIcon, + XIcon, +} from "~/components/Icons"; import Loading from "~/components/Loading"; import QRCodeScanner from "~/components/QRCodeScanner"; import Screen from "~/components/Screen"; @@ -28,6 +34,9 @@ import { REQUIRED_CAPABILITIES } from "~/lib/constants"; import { errorToast } from "~/lib/errorToast"; export function SetupWallet() { + const { nwcUrl: nwcUrlFromSchemeLink } = useLocalSearchParams<{ + nwcUrl: string; + }>(); const wallets = useAppStore((store) => store.wallets); const walletIdWithConnection = wallets.findIndex( (wallet) => wallet.nostrWalletConnectUrl, @@ -39,6 +48,7 @@ export function SetupWallet() { const [capabilities, setCapabilities] = React.useState(); const [name, setName] = React.useState(""); + const [startScanning, setStartScanning] = React.useState(false); const handleScanned = (data: string) => { return connect(data); @@ -56,47 +66,43 @@ export function SetupWallet() { connect(nostrWalletConnectUrl); } - async function connect(nostrWalletConnectUrl: string) { - try { - setConnecting(true); - // make sure connection is valid - const nwcClient = new nwc.NWCClient({ - nostrWalletConnectUrl, - }); - const info = await nwcClient.getInfo(); - const capabilities = [...info.methods] as Nip47Capability[]; - if (info.notifications?.length) { - capabilities.push("notifications"); - } - if ( - !REQUIRED_CAPABILITIES.every((capability) => - capabilities.includes(capability), - ) - ) { - const missing = REQUIRED_CAPABILITIES.filter( - (capability) => !capabilities.includes(capability), - ); - throw new Error(`Missing required capabilities: ${missing.join(", ")}`); - } + const connect = React.useCallback( + async (nostrWalletConnectUrl: string): Promise => { + try { + setConnecting(true); + // make sure connection is valid + const nwcClient = new nwc.NWCClient({ + nostrWalletConnectUrl, + }); + const info = await nwcClient.getInfo(); + const capabilities = [...info.methods] as Nip47Capability[]; + if (info.notifications?.length) { + capabilities.push("notifications"); + } - console.info("NWC connected", info); + console.info("NWC connected", info); - setNostrWalletConnectUrl(nostrWalletConnectUrl); - setCapabilities(capabilities); - setName(nwcClient.lud16 || ""); + setNostrWalletConnectUrl(nostrWalletConnectUrl); + setCapabilities(capabilities); + setName(nwcClient.lud16 || ""); - Toast.show({ - type: "success", - text1: "Connection successful", - text2: "Please set your wallet name to finish", - position: "top", - }); - } catch (error) { - console.error(error); - errorToast(error); - } - setConnecting(false); - } + Toast.show({ + type: "success", + text1: "Connection successful", + text2: "Please set your wallet name to finish", + position: "top", + }); + setConnecting(false); + return true; + } catch (error) { + console.error(error); + errorToast(error); + } + setConnecting(false); + return false; + }, + [], + ); const addWallet = () => { if (!nostrWalletConnectUrl) { @@ -122,6 +128,22 @@ export function SetupWallet() { router.replace("/"); }; + React.useEffect(() => { + if (nwcUrlFromSchemeLink) { + (async () => { + const result = await connect(nwcUrlFromSchemeLink); + // Delay the camera to show the error message + if (!result) { + setTimeout(() => { + setStartScanning(true); + }, 2000); + } + })(); + } else { + setStartScanning(true); + } + }, [connect, nwcUrlFromSchemeLink]); + return ( <> ) : !nostrWalletConnectUrl ? ( <> - + diff --git a/pages/withdraw/Withdraw.tsx b/pages/withdraw/Withdraw.tsx index 6aeebaa8..f103abc3 100644 --- a/pages/withdraw/Withdraw.tsx +++ b/pages/withdraw/Withdraw.tsx @@ -14,6 +14,7 @@ import { Text } from "~/components/ui/text"; import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; import { errorToast } from "~/lib/errorToast"; import { useAppStore } from "~/lib/state/appStore"; +import { cn } from "~/lib/utils"; export function Withdraw() { const { url } = useLocalSearchParams<{ url: string }>(); @@ -26,6 +27,16 @@ export function Withdraw() { const [lnurlDetails, setLnurlDetails] = React.useState(); + const isAmountInvalid = React.useMemo(() => { + if (!lnurlDetails) { + return true; + } + const min = Math.floor(lnurlDetails.minWithdrawable / 1000); + const max = Math.floor(lnurlDetails.maxWithdrawable / 1000); + + return Number(valueSat) < min || Number(valueSat) > max; + }, [valueSat, lnurlDetails]); + // Delay starting the QR scanner if url has valid lnurl withdraw info useEffect(() => { if (url) { @@ -109,17 +120,6 @@ export function Withdraw() { return; } - if (Number(valueSat) < lnurlDetails.minWithdrawable / 1000) { - throw new Error( - `Amount below minimum limit of ${lnurlDetails.minWithdrawable} sats`, - ); - } - if (Number(valueSat) > lnurlDetails.maxWithdrawable / 1000) { - throw new Error( - `Amount exceeds maximum limit of ${lnurlDetails.maxWithdrawable} sats.`, - ); - } - setLoadingConfirm(true); const nwcClient = useAppStore.getState().nwcClient; @@ -222,8 +222,28 @@ export function Withdraw() { + + + Between{" "} + {new Intl.NumberFormat().format( + Math.floor(lnurlDetails.minWithdrawable / 1000), + )} + {" and "} + {new Intl.NumberFormat().format( + Math.floor(lnurlDetails.maxWithdrawable / 1000), + )}{" "} + sats + + Description @@ -239,7 +259,7 @@ export function Withdraw() { size="lg" className="flex flex-row gap-2" onPress={confirm} - disabled={loadingConfirm} + disabled={loadingConfirm || isAmountInvalid} > {loadingConfirm && ( diff --git a/yarn.lock b/yarn.lock index 19ce1ea4..6fa3074b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7908,10 +7908,10 @@ react-native-reanimated@~3.16.1: convert-source-map "^2.0.0" invariant "^2.2.4" -react-native-safe-area-context@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz#17868522a55bbc6757418c94a1b4abdda6b045d9" - integrity sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ== +react-native-safe-area-context@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-5.0.0.tgz#0f28f3b406d4466c6afdaaa615198d12741e88b5" + integrity sha512-4K4TvEbRsTDYuSSJZfMNKuJNn1+qgrSkOBwRoreiHcuqy1egrHpkhPhoN1Zg1+b3BxcVXlKXtMIf4eVaG/DPJw== react-native-screens@~4.1.0: version "4.1.0"