├── .eslintrc.js ├── .github └── workflows │ ├── linting.yml │ ├── pull_requests.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── RELEASE.md ├── animations └── Tick.tsx ├── app.config.js ├── app ├── (app) │ ├── _layout.tsx │ ├── index.js │ ├── receive │ │ ├── alby-account.js │ │ ├── index.js │ │ ├── invoice.js │ │ ├── lightning-address.js │ │ ├── success.js │ │ └── withdraw.js │ ├── send │ │ ├── 0-amount.js │ │ ├── address-book.js │ │ ├── confirm.js │ │ ├── index.js │ │ ├── lnurl-pay.js │ │ ├── manual.js │ │ └── success.js │ ├── settings │ │ ├── address-book │ │ │ └── new.js │ │ ├── bitcoin-map.js │ │ ├── fiat-currency.js │ │ ├── index.js │ │ ├── notifications.js │ │ ├── security.js │ │ └── wallets │ │ │ ├── [id] │ │ │ ├── index.js │ │ │ ├── lightning-address.js │ │ │ └── name.js │ │ │ ├── connect.js │ │ │ ├── index.js │ │ │ └── setup.js │ ├── transaction.js │ └── transactions.js ├── [...wildcard].js ├── _layout.tsx ├── onboarding.js └── unlock.js ├── assets ├── adaptive-icon-bg.png ├── adaptive-icon.png ├── alby-account.png ├── alby-go-logo-dark.png ├── alby-go-logo.png ├── android │ └── MessagingService.kt ├── animations │ └── success.json ├── btc-map.png ├── fonts │ ├── Inter-Regular.otf │ ├── OpenRunde-Bold.otf │ ├── OpenRunde-Medium.otf │ ├── OpenRunde-Regular.otf │ └── OpenRunde-Semibold.otf ├── hub.png ├── icon.png ├── ios │ └── NotificationService.m ├── left-plug.png ├── logo.png ├── monochromatic.png ├── notification.png └── right-plug.png ├── babel.config.js ├── components.json ├── components ├── AlbyBanner.tsx ├── AlbyGoLogo.tsx ├── Alert.tsx ├── BTCMapModal.tsx ├── CreateInvoice.tsx ├── DismissableKeyboardView.tsx ├── DualCurrencyInput.tsx ├── FocusableCamera.tsx ├── HelpModal.tsx ├── Icons.tsx ├── LinearGradient.tsx ├── Loading.tsx ├── QRCode.tsx ├── QRCodeScanner.tsx ├── Receiver.tsx ├── Screen.tsx ├── ToastConfig.tsx ├── TransactionItem.tsx ├── WalletSwitcher.tsx ├── icons │ ├── FailedTransaction.tsx │ ├── LargeArrowDown.tsx │ ├── LargeArrowUp.tsx │ ├── NWCIcon.tsx │ ├── PendingTransaction.tsx │ ├── ReceivedTransaction.tsx │ ├── RedeemIcon.tsx │ └── SentTransaction.tsx ├── primitives │ ├── label │ │ ├── index.tsx │ │ └── types.ts │ ├── slot.tsx │ └── types.ts └── ui │ ├── button.tsx │ ├── card.tsx │ ├── input.tsx │ ├── label.tsx │ ├── skeleton.tsx │ ├── switch.tsx │ └── text.tsx ├── context ├── Notification.tsx └── UserInactivity.tsx ├── eas.json ├── global.css ├── hooks ├── __tests__ │ └── useHandleLinking.ts ├── useBalance.ts ├── useGetFiatAmount.ts ├── useHandleLinking.ts ├── useInfo.ts ├── useSession.tsx └── useTransactions.ts ├── index.js ├── lib ├── applyGlobalPolyfills.ts ├── bech32.ts ├── constants.ts ├── createNwcFetcher.ts ├── errorToast.ts ├── initiatePaymentFlow.ts ├── isBiometricSupported.ts ├── link.ts ├── lnurl.ts ├── merchants.ts ├── notifications.ts ├── secureStorage.ts ├── state │ └── appStore.ts ├── swr.ts ├── useColorScheme.tsx ├── utils.ts └── walletInfo.ts ├── lint-staged.config.js ├── metro.config.js ├── native └── notifications │ └── service.ts ├── nativewind-env.d.ts ├── package.json ├── pages ├── Home.tsx ├── Onboarding.tsx ├── Transaction.tsx ├── Transactions.tsx ├── Unlock.tsx ├── Wildcard.tsx ├── receive │ ├── AlbyAccount.tsx │ ├── Invoice.tsx │ ├── LightningAddress.tsx │ ├── Receive.tsx │ ├── ReceiveSuccess.tsx │ └── Withdraw.tsx ├── send │ ├── AddressBook.tsx │ ├── ConfirmPayment.tsx │ ├── LNURLPay.tsx │ ├── Manual.tsx │ ├── PaymentSuccess.tsx │ ├── Send.tsx │ └── ZeroAmount.tsx └── settings │ ├── BitcoinMap.tsx │ ├── FiatCurrency.tsx │ ├── Notifications.tsx │ ├── Security.tsx │ ├── Settings.tsx │ ├── Wallets.tsx │ ├── address-book │ └── NewAddressBookEntry.tsx │ └── wallets │ ├── ConnectWallet.tsx │ ├── EditWallet.tsx │ ├── LightningAddress.tsx │ ├── RenameWallet.tsx │ └── SetupWallet.tsx ├── plugins ├── android │ └── withMessageServicePlugin.js └── ios │ └── withOpenSSLPlugin.js ├── services └── Notifications.ts ├── tailwind.config.js ├── tsconfig.json ├── webln-types.d.ts ├── yarn.lock └── zapstore.yaml /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://docs.expo.dev/guides/using-eslint/ 2 | module.exports = { 3 | extends: ["expo", "prettier"], 4 | plugins: ["prettier"], 5 | rules: { 6 | "@typescript-eslint/ban-ts-comment": [ 7 | "error", 8 | { 9 | "ts-ignore": "allow-with-description", 10 | }, 11 | ], 12 | "@typescript-eslint/no-unused-vars": [ 13 | "warn", 14 | { 15 | args: "none", 16 | }, 17 | ], 18 | "no-console": ["error", { allow: ["info", "warn", "error"] }], 19 | "no-constant-binary-expression": "error", 20 | curly: "error", 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Code quality - linting and typechecking 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize] 9 | 10 | jobs: 11 | linting: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: 🏗 Setup repo 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up Java 18 | uses: actions/setup-java@v2 19 | with: 20 | java-version: 17 21 | distribution: "temurin" 22 | 23 | - name: 🏗 Setup Node 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 18.x 27 | cache: yarn 28 | 29 | - name: 🏗 Setup EAS 30 | uses: expo/expo-github-action@v8 31 | with: 32 | eas-version: latest 33 | token: ${{ secrets.EXPO_TOKEN }} 34 | 35 | - name: 📦 Install dependencies 36 | run: yarn install 37 | 38 | - name: 🧹 Linting 39 | run: yarn lint:js 40 | 41 | - name: ✨ Prettier 42 | run: yarn format 43 | 44 | - name: 🔎 Typechecking 45 | run: yarn tsc:compile 46 | -------------------------------------------------------------------------------- /.github/workflows/pull_requests.yml: -------------------------------------------------------------------------------- 1 | name: PR build 2 | on: [pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: 🏗 Setup repo 9 | uses: actions/checkout@v3 10 | 11 | - name: Set up Java 12 | uses: actions/setup-java@v2 13 | with: 14 | java-version: 17 15 | distribution: "temurin" 16 | 17 | - name: 🏗 Setup Node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18.x 21 | cache: yarn 22 | 23 | - name: 🏗 Setup EAS 24 | uses: expo/expo-github-action@v8 25 | with: 26 | eas-version: latest 27 | token: ${{ secrets.EXPO_TOKEN }} 28 | 29 | - name: 📦 Install dependencies 30 | run: yarn install 31 | 32 | - name: 🏗 Create google-services.json file 33 | run: echo "${{ secrets.GOOGLE_SERVICES_JSON_B64 }}" | base64 -d > ./google-services.json 34 | 35 | - name: 🚀 Build app 36 | run: eas build --non-interactive --platform android --local --profile preview --output=./app-release.apk 37 | env: 38 | GOOGLE_SERVICES_JSON: "/home/runner/work/go/go/google-services.json" 39 | 40 | - name: 🧪 Run tests 41 | run: yarn test 42 | 43 | - name: Upload Artifact 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: alby-go-android.apk 47 | path: ./app-release.apk 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 7 | 8 | jobs: 9 | draft_release: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | upload_url: ${{ steps.create_release.outputs.upload_url }} 13 | id: ${{ steps.create_release.outputs.id }} 14 | steps: 15 | # Create Release 16 | - name: Create Release 17 | id: create_release 18 | uses: actions/create-release@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | tag_name: ${{ github.ref }} 23 | release_name: Release ${{ github.ref }} 24 | draft: true 25 | # prerelease: true 26 | 27 | build_android: 28 | needs: [draft_release] 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: 🏗 Setup repo 32 | uses: actions/checkout@v3 33 | 34 | - name: Set up Java 35 | uses: actions/setup-java@v2 36 | with: 37 | java-version: 17 38 | distribution: "temurin" 39 | 40 | - name: 🏗 Setup Node 41 | uses: actions/setup-node@v3 42 | with: 43 | node-version: 18.x 44 | cache: yarn 45 | 46 | - name: 🏗 Setup EAS 47 | uses: expo/expo-github-action@v8 48 | with: 49 | eas-version: latest 50 | token: ${{ secrets.EXPO_TOKEN }} 51 | 52 | - name: 📦 Install dependencies 53 | run: yarn install 54 | 55 | - name: 🏗 Create google-services.json file 56 | run: echo "${{ secrets.GOOGLE_SERVICES_JSON_B64 }}" | base64 -d > ./google-services.json 57 | 58 | - name: 🚀 Build app 59 | run: eas build --non-interactive --platform android --local --profile production_apk --output=./app-release.apk 60 | env: 61 | GOOGLE_SERVICES_JSON: "/home/runner/work/go/go/google-services.json" 62 | 63 | - name: Upload Artifact 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: alby-go-android.apk 67 | path: ./app-release.apk 68 | 69 | # APK 70 | - name: Upload APK to release 71 | uses: actions/upload-release-asset@v1 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | with: 75 | upload_url: ${{ needs.draft_release.outputs.upload_url }} 76 | asset_path: ./app-release.apk 77 | asset_name: alby-go-${{ github.ref_name }}-android.apk 78 | asset_content_type: application/vnd.android.package-archive 79 | # publish-release: 80 | # needs: [release, build_android] 81 | # runs-on: ubuntu-latest 82 | # 83 | # steps: 84 | # - uses: eregon/publish-release@v1 85 | # env: 86 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 87 | # with: 88 | # release_id: ${{ needs.release.outputs.id }} 89 | # 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | 11 | # Native 12 | *.orig.* 13 | *.jks 14 | *.p8 15 | *.p12 16 | *.key 17 | *.mobileprovision 18 | 19 | # Metro 20 | .metro-health-check* 21 | 22 | # debug 23 | npm-debug.* 24 | yarn-debug.* 25 | yarn-error.* 26 | 27 | # macOS 28 | .DS_Store 29 | *.pem 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | /ios 38 | /android 39 | 40 | google-services.json 41 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint-staged -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | node_modules 5 | assets/** -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "typescript.preferences.importModuleSpecifier": "non-relative", 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": "always" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/user-attachments/assets/c41c4ae2-ab4f-4fd8-8012-c6d3fbd8ca87) 2 | 3 | # Alby Go 4 | 5 | A simple lightning mobile wallet interface that works great with [Alby Hub](https://albyhub.com) or any other [NWC](https://nwc.dev) wallet service. 6 | 7 | ## Development 8 | 9 | `yarn install` 10 | 11 | `yarn start` 12 | 13 | ### Notifications 14 | 15 | Push notifications are only available when running the app on a **physical device** using the following commands: 16 | 17 | For iOS: 18 | 19 | `yarn ios:device` 20 | 21 | For Android: 22 | 23 | `yarn android:device` 24 | 25 | **Note:** Notifications do not work in the Expo Go app. You must run the app on a standalone build or a device using the above commands. 26 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | 1. Update version in 4 | 5 | - `app.config.js` 6 | - `package.json` 7 | 8 | 2. Create a git tag and push it (a new draft release will be created) 9 | 10 | - `git tag v1.2.3` 11 | - `git push origin tag v1.2.3` 12 | - Update the release notes and publish the release (APK will be built and added automatically) 13 | 14 | 3. Build packages 15 | 16 | - `yarn eas:build:android` 17 | - `yarn eas:build:ios` 18 | 19 | 3. Submit to app stores 20 | 21 | - `eas submit --platform android` 22 | - `eas submit --platform ios` 23 | 24 | # Zapstore 25 | 26 | Install required software: 27 | 28 | - `sudo apt install apksigner apktool` 29 | - https://github.com/sibprogrammer/xq 30 | - https://github.com/zapstore/zapstore-cli 31 | 32 | Then publish the release 33 | 34 | 1. `zapstore publish albygo -v ` ( without the `v` prefix) 35 | 1. Use nsec to sign release events 36 | -------------------------------------------------------------------------------- /animations/Tick.tsx: -------------------------------------------------------------------------------- 1 | import LottieView from "lottie-react-native"; 2 | 3 | export function Tick() { 4 | return ( 5 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app.config.js: -------------------------------------------------------------------------------- 1 | import withMessagingServicePlugin from "./plugins/android/withMessageServicePlugin"; 2 | import withOpenSSLPlugin from "./plugins/ios/withOpenSSLPlugin"; 3 | 4 | export default ({ config }) => { 5 | return { 6 | ...config, 7 | name: "Alby Go", 8 | slug: "alby-mobile", 9 | version: "1.13.1", 10 | scheme: [ 11 | "lightning", 12 | "bitcoin", 13 | "alby", 14 | "nostr+walletconnect", 15 | "nostrnwc", 16 | "nostrnwc+alby", 17 | "nostr+walletauth", 18 | "nostr+walletauth+alby", 19 | ], 20 | orientation: "portrait", 21 | icon: "./assets/icon.png", 22 | userInterfaceStyle: "automatic", 23 | newArchEnabled: true, 24 | assetBundlePatterns: ["**/*"], 25 | plugins: [ 26 | [ 27 | withMessagingServicePlugin, 28 | { 29 | androidFMSFilePath: "./assets/android/MessagingService.kt", 30 | }, 31 | ], 32 | [withOpenSSLPlugin], 33 | [ 34 | "expo-notification-service-extension-plugin", 35 | { 36 | mode: "production", 37 | iosNSEFilePath: "./assets/ios/NotificationService.m", 38 | }, 39 | ], 40 | [ 41 | "expo-splash-screen", 42 | { 43 | backgroundColor: "#0B0930", 44 | image: "./assets/icon.png", 45 | imageWidth: "150", 46 | }, 47 | ], 48 | [ 49 | "expo-local-authentication", 50 | { 51 | faceIDPermission: "Allow Alby Go to use Face ID.", 52 | }, 53 | ], 54 | [ 55 | "expo-camera", 56 | { 57 | cameraPermission: 58 | "Allow Alby Go to use the camera to scan wallet connection and payment QR codes", 59 | recordAudioAndroid: false, 60 | }, 61 | ], 62 | [ 63 | "expo-font", 64 | { 65 | fonts: [ 66 | "./assets/fonts/OpenRunde-Regular.otf", 67 | "./assets/fonts/OpenRunde-Medium.otf", 68 | "./assets/fonts/OpenRunde-Semibold.otf", 69 | "./assets/fonts/OpenRunde-Bold.otf", 70 | ], 71 | }, 72 | ], 73 | [ 74 | "expo-notifications", 75 | { 76 | icon: "./assets/notification.png", 77 | }, 78 | ], 79 | "expo-location", 80 | "expo-router", 81 | "expo-secure-store", 82 | ], 83 | ios: { 84 | supportsTablet: true, 85 | bundleIdentifier: "com.getalby.mobile", 86 | config: { 87 | usesNonExemptEncryption: false, 88 | }, 89 | infoPlist: { 90 | LSMinimumSystemVersion: "12.0", 91 | UIBackgroundModes: ["remote-notification"], 92 | }, 93 | userInterfaceStyle: "automatic", 94 | }, 95 | android: { 96 | package: "com.getalby.mobile", 97 | icon: "./assets/icon.png", 98 | adaptiveIcon: { 99 | foregroundImage: "./assets/adaptive-icon.png", 100 | backgroundImage: "./assets/adaptive-icon-bg.png", 101 | monochromeImage: "./assets/monochromatic.png", 102 | }, 103 | permissions: [ 104 | "android.permission.CAMERA", 105 | "android.permission.USE_BIOMETRIC", 106 | "android.permission.USE_FINGERPRINT", 107 | ], 108 | userInterfaceStyle: "automatic", 109 | googleServicesFile: process.env.GOOGLE_SERVICES_JSON, 110 | }, 111 | extra: { 112 | eas: { 113 | projectId: "294965ec-3a67-4994-8794-5cc1117ef155", 114 | }, 115 | }, 116 | owner: "roland_alby", 117 | }; 118 | }; 119 | -------------------------------------------------------------------------------- /app/(app)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect, Stack } from "expo-router"; 2 | import { useRouteInfo } from "expo-router/build/hooks"; 3 | import { useHandleLinking } from "~/hooks/useHandleLinking"; 4 | import { useSession } from "~/hooks/useSession"; 5 | import { useAppStore } from "~/lib/state/appStore"; 6 | 7 | export default function AppLayout() { 8 | const { hasSession } = useSession(); 9 | const isOnboarded = useAppStore((store) => store.isOnboarded); 10 | const wallets = useAppStore((store) => store.wallets); 11 | const route = useRouteInfo(); 12 | useHandleLinking(); 13 | 14 | if (!isOnboarded) { 15 | console.info("Not onboarded, redirecting to /onboarding"); 16 | return ; 17 | } 18 | 19 | if (!hasSession) { 20 | console.info("Not authenticated, redirecting to /unlock"); 21 | return ; 22 | } 23 | 24 | const connectionPage = "/settings/wallets/setup"; 25 | // Check the current pathname to prevent redirect loops 26 | if (!wallets.length && route.pathname !== connectionPage) { 27 | console.info("No wallets available, redirecting to setup"); 28 | return ; 29 | } 30 | 31 | return ; 32 | } 33 | -------------------------------------------------------------------------------- /app/(app)/index.js: -------------------------------------------------------------------------------- 1 | import { Home } from "../../pages/Home"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/receive/alby-account.js: -------------------------------------------------------------------------------- 1 | import { AlbyAccount } from "../../../pages/receive/AlbyAccount"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/receive/index.js: -------------------------------------------------------------------------------- 1 | import { Receive } from "../../../pages/receive/Receive"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/receive/invoice.js: -------------------------------------------------------------------------------- 1 | import { Invoice } from "../../../pages/receive/Invoice"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/receive/lightning-address.js: -------------------------------------------------------------------------------- 1 | import { LightningAddress } from "../../../pages/receive/LightningAddress"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/receive/success.js: -------------------------------------------------------------------------------- 1 | import { ReceiveSuccess } from "../../../pages/receive/ReceiveSuccess"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/receive/withdraw.js: -------------------------------------------------------------------------------- 1 | import { Withdraw } from "../../../pages/receive/Withdraw"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/send/0-amount.js: -------------------------------------------------------------------------------- 1 | import { ZeroAmount } from "../../../pages/send/ZeroAmount"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/send/address-book.js: -------------------------------------------------------------------------------- 1 | import { AddressBook } from "../../../pages/send/AddressBook"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/send/confirm.js: -------------------------------------------------------------------------------- 1 | import { ConfirmPayment } from "../../../pages/send/ConfirmPayment"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/send/index.js: -------------------------------------------------------------------------------- 1 | import { Send } from "../../../pages/send/Send"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/send/lnurl-pay.js: -------------------------------------------------------------------------------- 1 | import { LNURLPay } from "../../../pages/send/LNURLPay"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/send/manual.js: -------------------------------------------------------------------------------- 1 | import { Manual } from "../../../pages/send/Manual"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/send/success.js: -------------------------------------------------------------------------------- 1 | import { PaymentSuccess } from "../../../pages/send/PaymentSuccess"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/address-book/new.js: -------------------------------------------------------------------------------- 1 | import { NewAddressBookEntry } from "../../../../pages/settings/address-book/NewAddressBookEntry"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/bitcoin-map.js: -------------------------------------------------------------------------------- 1 | import { BitcoinMap } from "../../../pages/settings/BitcoinMap"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/fiat-currency.js: -------------------------------------------------------------------------------- 1 | import { FiatCurrency } from "../../../pages/settings/FiatCurrency"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/index.js: -------------------------------------------------------------------------------- 1 | import { Settings } from "../../../pages/settings/Settings"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/notifications.js: -------------------------------------------------------------------------------- 1 | import { Notifications } from "../../../pages/settings/Notifications"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/security.js: -------------------------------------------------------------------------------- 1 | import { Security } from "../../../pages/settings/Security"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/wallets/[id]/index.js: -------------------------------------------------------------------------------- 1 | import { EditWallet } from "../../../../../pages/settings/wallets/EditWallet"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/wallets/[id]/lightning-address.js: -------------------------------------------------------------------------------- 1 | import { SetLightningAddress } from "../../../../../pages/settings/wallets/LightningAddress"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/wallets/[id]/name.js: -------------------------------------------------------------------------------- 1 | import { RenameWallet } from "../../../../../pages/settings/wallets/RenameWallet"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/wallets/connect.js: -------------------------------------------------------------------------------- 1 | import { ConnectWallet } from "../../../../pages/settings/wallets/ConnectWallet"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/wallets/index.js: -------------------------------------------------------------------------------- 1 | import { Wallets } from "../../../../pages/settings/Wallets"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/settings/wallets/setup.js: -------------------------------------------------------------------------------- 1 | import { SetupWallet } from "../../../../pages/settings/wallets/SetupWallet"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/transaction.js: -------------------------------------------------------------------------------- 1 | import { Transaction } from "../../pages/Transaction"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/transactions.js: -------------------------------------------------------------------------------- 1 | import { Transactions } from "../../pages/Transactions"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[...wildcard].js: -------------------------------------------------------------------------------- 1 | import { Wildcard } from "../pages/Wildcard"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; 2 | import { 3 | DarkTheme, 4 | DefaultTheme, 5 | Theme, 6 | ThemeProvider, 7 | } from "@react-navigation/native"; 8 | import { PortalHost } from "@rn-primitives/portal"; 9 | import * as Font from "expo-font"; 10 | import { Slot } from "expo-router"; 11 | import * as SplashScreen from "expo-splash-screen"; 12 | import { StatusBar } from "expo-status-bar"; 13 | import { swrConfiguration } from "lib/swr"; 14 | import * as React from "react"; 15 | import { GestureHandlerRootView } from "react-native-gesture-handler"; 16 | import { SafeAreaView } from "react-native-safe-area-context"; 17 | import Toast from "react-native-toast-message"; 18 | import { SWRConfig } from "swr"; 19 | import { toastConfig } from "~/components/ToastConfig"; 20 | import { NotificationProvider } from "~/context/Notification"; 21 | import { UserInactivityProvider } from "~/context/UserInactivity"; 22 | import "~/global.css"; 23 | import { SessionProvider } from "~/hooks/useSession"; 24 | import { IS_EXPO_GO, NAV_THEME } from "~/lib/constants"; 25 | import { isBiometricSupported } from "~/lib/isBiometricSupported"; 26 | import { useAppStore } from "~/lib/state/appStore"; 27 | import { useColorScheme } from "~/lib/useColorScheme"; 28 | import { registerForPushNotificationsAsync } from "~/services/Notifications"; 29 | 30 | const LIGHT_THEME: Theme = { 31 | ...DefaultTheme, 32 | colors: NAV_THEME.light, 33 | }; 34 | const DARK_THEME: Theme = { 35 | ...DarkTheme, 36 | colors: NAV_THEME.dark, 37 | }; 38 | 39 | export { 40 | // Catch any errors thrown by the Layout component. 41 | ErrorBoundary, 42 | } from "expo-router"; 43 | 44 | // Prevent the splash screen from auto-hiding before getting the color scheme. 45 | SplashScreen.preventAutoHideAsync(); 46 | 47 | export const unstable_settings = { 48 | initialRouteName: "(app)/index", 49 | }; 50 | 51 | export default function RootLayout() { 52 | const { isDarkColorScheme, setColorScheme } = useColorScheme(); 53 | const [resourcesLoaded, setResourcesLoaded] = React.useState(false); 54 | 55 | async function loadFonts() { 56 | await Font.loadAsync({ 57 | OpenRunde: require("./../assets/fonts/OpenRunde-Regular.otf"), 58 | "OpenRunde-Medium": require("./../assets/fonts/OpenRunde-Medium.otf"), 59 | "OpenRunde-Semibold": require("./../assets/fonts/OpenRunde-Semibold.otf"), 60 | "OpenRunde-Bold": require("./../assets/fonts/OpenRunde-Bold.otf"), 61 | }); 62 | } 63 | 64 | async function checkBiometricStatus() { 65 | const isSupported = await isBiometricSupported(); 66 | if (!isSupported) { 67 | useAppStore.getState().setSecurityEnabled(false); 68 | } 69 | } 70 | 71 | // TODO: Do not prompt the user at all if FCM is disabled 72 | async function checkAndPromptForNotifications() { 73 | const isEnabled = useAppStore.getState().isNotificationsEnabled; 74 | // prompt the user to enable notifications on first open 75 | if (isEnabled === null) { 76 | const enabled = await registerForPushNotificationsAsync(); 77 | useAppStore.getState().setNotificationsEnabled(enabled); 78 | } 79 | } 80 | 81 | const loadTheme = React.useCallback((): Promise => { 82 | return new Promise((resolve) => { 83 | const theme = useAppStore.getState().theme; 84 | if (theme) { 85 | setColorScheme(theme); 86 | } else { 87 | useAppStore.getState().setTheme(isDarkColorScheme ? "dark" : "light"); 88 | } 89 | resolve(); 90 | }); 91 | }, [isDarkColorScheme, setColorScheme]); 92 | 93 | React.useEffect(() => { 94 | const init = async () => { 95 | try { 96 | await Promise.all([loadTheme(), loadFonts(), checkBiometricStatus()]); 97 | } finally { 98 | setResourcesLoaded(true); 99 | if (!IS_EXPO_GO) { 100 | await checkAndPromptForNotifications(); 101 | } 102 | SplashScreen.hide(); 103 | } 104 | }; 105 | 106 | init(); 107 | }, [loadTheme]); 108 | 109 | if (!resourcesLoaded) { 110 | return null; 111 | } 112 | 113 | return ( 114 | 115 | 116 | 117 | 118 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | ); 143 | } 144 | -------------------------------------------------------------------------------- /app/onboarding.js: -------------------------------------------------------------------------------- 1 | import { Onboarding } from "../pages/Onboarding"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/unlock.js: -------------------------------------------------------------------------------- 1 | import { Unlock } from "../pages/Unlock"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /assets/adaptive-icon-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/adaptive-icon-bg.png -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/adaptive-icon.png -------------------------------------------------------------------------------- /assets/alby-account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/alby-account.png -------------------------------------------------------------------------------- /assets/alby-go-logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/alby-go-logo-dark.png -------------------------------------------------------------------------------- /assets/alby-go-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/alby-go-logo.png -------------------------------------------------------------------------------- /assets/btc-map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/btc-map.png -------------------------------------------------------------------------------- /assets/fonts/Inter-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/fonts/Inter-Regular.otf -------------------------------------------------------------------------------- /assets/fonts/OpenRunde-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/fonts/OpenRunde-Bold.otf -------------------------------------------------------------------------------- /assets/fonts/OpenRunde-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/fonts/OpenRunde-Medium.otf -------------------------------------------------------------------------------- /assets/fonts/OpenRunde-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/fonts/OpenRunde-Regular.otf -------------------------------------------------------------------------------- /assets/fonts/OpenRunde-Semibold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/fonts/OpenRunde-Semibold.otf -------------------------------------------------------------------------------- /assets/hub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/hub.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/icon.png -------------------------------------------------------------------------------- /assets/left-plug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/left-plug.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/logo.png -------------------------------------------------------------------------------- /assets/monochromatic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/monochromatic.png -------------------------------------------------------------------------------- /assets/notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/notification.png -------------------------------------------------------------------------------- /assets/right-plug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getAlby/go/ce1993bdc5e0e71e707e0e8245339e0bdc31a83a/assets/right-plug.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: [ 5 | ["babel-preset-expo", { jsxImportSource: "nativewind" }], 6 | "nativewind/babel", 7 | "@babel/preset-typescript", 8 | ], 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "platforms": "native-only", 3 | "aliases": { 4 | "components": "~/components", 5 | "lib": "~/lib" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /components/AlbyBanner.tsx: -------------------------------------------------------------------------------- 1 | import { router } from "expo-router"; 2 | import { View } from "react-native"; 3 | import { Text } from "~/components/ui/text"; 4 | import { ALBY_LIGHTNING_ADDRESS } from "~/lib/constants"; 5 | import { useAppStore } from "~/lib/state/appStore"; 6 | import { Button } from "./ui/button"; 7 | 8 | function AlbyBanner() { 9 | const lastPayment = useAppStore.getState().getLastAlbyPayment(); 10 | const showAlbyBanner = isPaymentOlderThan24Hours(lastPayment); 11 | const amounts = [ 12 | { value: 1000, label: "1k", emoji: "🧡" }, 13 | { value: 5000, label: "5k", emoji: "🔥" }, 14 | { value: 10000, label: "10k", emoji: "🚀" }, 15 | ]; 16 | 17 | function isPaymentOlderThan24Hours(paymentDate: Date | null) { 18 | if (!paymentDate) { 19 | return true; 20 | } 21 | 22 | const currentDate = new Date(); 23 | const millisecondsIn24Hours = 24 * 60 * 60 * 1000; // 24 hours 24 | 25 | return ( 26 | currentDate.getTime() - paymentDate.getTime() > millisecondsIn24Hours 27 | ); 28 | } 29 | 30 | if (!showAlbyBanner) { 31 | return null; 32 | } 33 | 34 | return ( 35 | 36 | ✨ Enjoying Alby Go? 37 | 38 | Help us grow and improve by supporting our development. 39 | 40 | 41 | {amounts.map(({ value, label, emoji }) => ( 42 | 61 | ))} 62 | 63 | 64 | ); 65 | } 66 | 67 | export default AlbyBanner; 68 | -------------------------------------------------------------------------------- /components/AlbyGoLogo.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from "react-native"; 2 | import { useColorScheme } from "~/lib/useColorScheme"; 3 | 4 | function AlbyGoLogo({ className = "" }) { 5 | const { isDarkColorScheme } = useColorScheme(); 6 | 7 | const lightModeImage = require(`../assets/alby-go-logo.png`); 8 | const darkModeImage = require(`../assets/alby-go-logo-dark.png`); 9 | 10 | const image = isDarkColorScheme ? darkModeImage : lightModeImage; 11 | 12 | return ; 13 | } 14 | 15 | export default AlbyGoLogo; 16 | -------------------------------------------------------------------------------- /components/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { View } from "react-native"; 2 | import { SvgProps } from "react-native-svg"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardTitle, 8 | } from "~/components/ui/card"; 9 | import { cn } from "~/lib/utils"; 10 | 11 | type Props = { 12 | type: "error" | "warn" | "info"; 13 | icon: React.FunctionComponent; 14 | title: string; 15 | description: string; 16 | className?: string; 17 | }; 18 | 19 | function Alert({ title, description, type, icon: Icon, className }: Props) { 20 | const textColor = 21 | type === "error" 22 | ? "text-red-700 dark:text-red-300" 23 | : type === "warn" 24 | ? "text-orange-700 dark:text-orange-300" 25 | : "text-blue-700 dark:text-blue-300"; 26 | return ( 27 | 39 | 40 | 41 | 42 | {title} 43 | {description} 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | export default Alert; 51 | -------------------------------------------------------------------------------- /components/BTCMapModal.tsx: -------------------------------------------------------------------------------- 1 | import { openURL } from "expo-linking"; 2 | import React from "react"; 3 | import { Image, Modal, TouchableOpacity, View } from "react-native"; 4 | import { XIcon } from "~/components/Icons"; 5 | import { Text } from "~/components/ui/text"; 6 | 7 | type BTCMapModalProps = { 8 | visible: boolean; 9 | onClose: () => void; 10 | }; 11 | 12 | function BTCMapModal({ visible, onClose }: BTCMapModalProps) { 13 | return ( 14 | 20 | 21 | 26 | 27 | 28 | 29 | BTC Map 30 | 31 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 46 | BTC Map is an open-source project with the goal of mapping and 47 | maintaining all the merchants accepting Bitcoin around the 48 | world. 49 | 50 | 51 | Find merchants nearby, pay for goods and services, and help 52 | improve the map by contributing! 53 | 54 | openURL("https://btcmap.org/")} 56 | className="text-lg underline font-semibold2 text-muted-foreground" 57 | > 58 | Visit btcmap.org 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | } 67 | 68 | export default BTCMapModal; 69 | -------------------------------------------------------------------------------- /components/DismissableKeyboardView.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Keyboard, 3 | KeyboardAvoidingView, 4 | Platform, 5 | TouchableWithoutFeedback, 6 | } from "react-native"; 7 | 8 | function DismissableKeyboardView({ children }: { children?: React.ReactNode }) { 9 | return ( 10 | 15 | 16 | {children} 17 | 18 | 19 | ); 20 | } 21 | 22 | export default DismissableKeyboardView; 23 | -------------------------------------------------------------------------------- /components/DualCurrencyInput.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, TextInput, TouchableOpacity, View } from "react-native"; 3 | import { SwapIcon } from "~/components/Icons"; 4 | import { useGetFiatAmount, useGetSatsAmount } from "~/hooks/useGetFiatAmount"; 5 | import { 6 | CURSOR_COLOR, 7 | DEFAULT_CURRENCY, 8 | FIAT_REGEX, 9 | SATS_REGEX, 10 | } from "~/lib/constants"; 11 | import { useAppStore } from "~/lib/state/appStore"; 12 | import { cn } from "~/lib/utils"; 13 | import { Input } from "./ui/input"; 14 | import { Text } from "./ui/text"; 15 | 16 | type DualCurrencyInputProps = { 17 | amount: string; 18 | setAmount(amount: string): void; 19 | autoFocus?: boolean; 20 | readOnly?: boolean; 21 | max?: number; 22 | min?: number; 23 | }; 24 | 25 | export function DualCurrencyInput({ 26 | amount, 27 | setAmount, 28 | autoFocus = false, 29 | readOnly = false, 30 | max, 31 | min, 32 | }: DualCurrencyInputProps) { 33 | const getFiatAmount = useGetFiatAmount(); 34 | const getSatsAmount = useGetSatsAmount(); 35 | const fiatCurrency = 36 | useAppStore((store) => store.fiatCurrency) || DEFAULT_CURRENCY; 37 | const [fiatAmount, setFiatAmount] = React.useState(""); 38 | const [inputMode, setInputMode] = React.useState<"sats" | "fiat">("sats"); 39 | const inputRef = React.useRef(null); 40 | 41 | function onChangeText(text: string) { 42 | if (inputMode === "sats") { 43 | if (!SATS_REGEX.test(text)) { 44 | return; 45 | } 46 | setAmount(text); 47 | } else { 48 | if (!FIAT_REGEX.test(text)) { 49 | return; 50 | } 51 | setFiatAmount(text); 52 | if (getSatsAmount) { 53 | const numericValue = +text.replace(",", "."); 54 | setAmount(getSatsAmount(numericValue)?.toString() || ""); 55 | } 56 | } 57 | } 58 | 59 | function toggleInputMode() { 60 | inputRef.current?.blur(); 61 | if (inputMode === "sats" && getFiatAmount) { 62 | setFiatAmount( 63 | amount ? getFiatAmount(+amount, false)?.toString() || "" : "", 64 | ); 65 | } 66 | const newMode = inputMode === "fiat" ? "sats" : "fiat"; 67 | setInputMode(newMode); 68 | setTimeout(() => { 69 | inputRef.current?.focus(); 70 | }, 100); 71 | } 72 | 73 | return ( 74 | 75 | max) || (min && Number(amount) < min)) && 81 | "text-destructive", 82 | )} 83 | placeholder="0" 84 | keyboardType={inputMode === "sats" ? "number-pad" : "decimal-pad"} 85 | value={inputMode === "sats" ? amount : fiatAmount} 86 | selectionColor={CURSOR_COLOR} 87 | onChangeText={onChangeText} 88 | aria-labelledbyledBy="amount" 89 | style={styles.amountInput} 90 | autoFocus={autoFocus} 91 | returnKeyType="done" 92 | readOnly={readOnly} 93 | // aria-errormessage="inputError" 94 | /> 95 | 96 | 97 | 98 | {inputMode === "fiat" ? fiatCurrency : "sats"} 99 | 100 | 101 | 102 | 103 | { 104 | 105 | {inputMode === "fiat" 106 | ? new Intl.NumberFormat().format(+amount) + " sats" 107 | : getFiatAmount?.(+amount) || ""} 108 | 109 | } 110 | 111 | ); 112 | } 113 | 114 | const styles = StyleSheet.create({ 115 | amountInput: { 116 | fontSize: 80, 117 | height: 90, 118 | }, 119 | }); 120 | -------------------------------------------------------------------------------- /components/FocusableCamera.tsx: -------------------------------------------------------------------------------- 1 | import { BarcodeScanningResult, CameraView } from "expo-camera"; 2 | import React from "react"; 3 | import { TouchableOpacity } from "react-native"; 4 | 5 | type FocusableCameraProps = { 6 | onScanned(data: string): void; 7 | }; 8 | 9 | export function FocusableCamera({ onScanned }: FocusableCameraProps) { 10 | const [autoFocus, setAutoFocus] = React.useState(true); 11 | 12 | const focusCamera = () => { 13 | setAutoFocus(false); 14 | setTimeout(() => { 15 | setAutoFocus(true); 16 | }, 200); 17 | }; 18 | 19 | const handleBarCodeScanned = ({ data }: BarcodeScanningResult) => { 20 | onScanned(data); 21 | }; 22 | return ( 23 | 31 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /components/HelpModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Modal, TouchableOpacity, View } from "react-native"; 3 | import { XIcon } from "~/components/Icons"; 4 | import { Button } from "~/components/ui/button"; 5 | import { Text } from "~/components/ui/text"; 6 | 7 | type HelpModalProps = { 8 | visible: boolean; 9 | onClose: () => void; 10 | }; 11 | 12 | function HelpModal({ visible, onClose }: HelpModalProps) { 13 | return ( 14 | 20 | 21 | 26 | 27 | 28 | 29 | Connect Your Wallet 30 | 31 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | Follow these steps to connect Alby Go to your Hub: 42 | 43 | 44 | 1. Open your Alby Hub 45 | 46 | 47 | 2. Go to App Store » Alby Go 48 | 49 | 50 | 3. Scan the QR code with this app 51 | 52 | 53 | 56 | 57 | 58 | 59 | 60 | ); 61 | } 62 | 63 | export default HelpModal; 64 | -------------------------------------------------------------------------------- /components/Icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PopiconsAtSymbolSolid as AddressIcon, 3 | PopiconsCircleExclamationLine as AlertCircleIcon, 4 | PopiconsBitcoinSolid as BitcoinIcon, 5 | PopiconsAddressBookSolid as BookUserIcon, 6 | PopiconsCameraWebOffSolid as CameraOffIcon, 7 | PopiconsCircleCheckLine as CheckCircleIcon, 8 | PopiconsChevronBottomLine as ChevronDownIcon, 9 | PopiconsChevronRightSolid as ChevronRightIcon, 10 | PopiconsChevronTopLine as ChevronUpIcon, 11 | PopiconsCopySolid as CopyIcon, 12 | PopiconsEditSolid as EditIcon, 13 | PopiconsUploadSolid as ExportIcon, 14 | PopiconsTouchIdSolid as FingerprintIcon, 15 | PopiconsCircleInfoSolid as HelpCircleIcon, 16 | PopiconsKeyboardSolid as KeyboardIcon, 17 | PopiconsLinkExternalSolid as LinkIcon, 18 | PopiconsArrowDownLine as MoveDownIcon, 19 | PopiconsArrowUpLine as MoveUpIcon, 20 | PopiconsNotificationSquareSolid as NotificationIcon, 21 | PopiconsLifebuoySolid as OnboardingIcon, 22 | PopiconsClipboardTextSolid as PasteIcon, 23 | PopiconsPinSolid as PinIcon, 24 | PopiconsQrCodeMinimalSolid as QRIcon, 25 | PopiconsReloadLine as RefreshIcon, 26 | PopiconsReloadSolid as ResetIcon, 27 | PopiconsFullscreenSolid as ScanIcon, 28 | PopiconsSettingsMinimalSolid as SettingsIcon, 29 | PopiconsShareSolid as ShareIcon, 30 | PopiconsLogoutSolid as SignOutIcon, 31 | PopiconsLoopSolid as SwapIcon, 32 | PopiconsPaintSolid as ThemeIcon, 33 | PopiconsBinSolid as TrashIcon, 34 | PopiconsTriangleExclamationLine as TriangleAlertIcon, 35 | PopiconsWalletHorizontalOpenSolid as WalletIcon, 36 | PopiconsCircleXLine as XCircleIcon, 37 | PopiconsXSolid as XIcon, 38 | PopiconsBoltSolid as ZapIcon, 39 | } from "@popicons/react-native"; 40 | import { cssInterop } from "nativewind"; 41 | import { SvgProps } from "react-native-svg"; 42 | 43 | function interopIcon(icon: React.FunctionComponent) { 44 | cssInterop(icon, { 45 | className: { 46 | target: "style", 47 | nativeStyleToProp: { 48 | color: true, 49 | opacity: true, 50 | }, 51 | }, 52 | }); 53 | } 54 | 55 | interopIcon(AddressIcon); 56 | interopIcon(AlertCircleIcon); 57 | interopIcon(BitcoinIcon); 58 | interopIcon(BookUserIcon); 59 | interopIcon(CameraOffIcon); 60 | interopIcon(CheckCircleIcon); 61 | interopIcon(ChevronDownIcon); 62 | interopIcon(ChevronRightIcon); 63 | interopIcon(ChevronUpIcon); 64 | interopIcon(CopyIcon); 65 | interopIcon(EditIcon); 66 | interopIcon(ExportIcon); 67 | interopIcon(FingerprintIcon); 68 | interopIcon(HelpCircleIcon); 69 | interopIcon(KeyboardIcon); 70 | interopIcon(LinkIcon); 71 | interopIcon(MoveDownIcon); 72 | interopIcon(MoveUpIcon); 73 | interopIcon(NotificationIcon); 74 | interopIcon(OnboardingIcon); 75 | interopIcon(PasteIcon); 76 | interopIcon(PinIcon); 77 | interopIcon(QRIcon); 78 | interopIcon(RefreshIcon); 79 | interopIcon(ResetIcon); 80 | interopIcon(ScanIcon); 81 | interopIcon(SettingsIcon); 82 | interopIcon(ShareIcon); 83 | interopIcon(SignOutIcon); 84 | interopIcon(SwapIcon); 85 | interopIcon(ThemeIcon); 86 | interopIcon(TrashIcon); 87 | interopIcon(TriangleAlertIcon); 88 | interopIcon(WalletIcon); 89 | interopIcon(XCircleIcon); 90 | interopIcon(XIcon); 91 | interopIcon(ZapIcon); 92 | 93 | export { 94 | AddressIcon, 95 | AlertCircleIcon, 96 | BitcoinIcon, 97 | BookUserIcon, 98 | CameraOffIcon, 99 | CheckCircleIcon, 100 | ChevronDownIcon, 101 | ChevronRightIcon, 102 | ChevronUpIcon, 103 | CopyIcon, 104 | EditIcon, 105 | ExportIcon, 106 | FingerprintIcon, 107 | HelpCircleIcon, 108 | KeyboardIcon, 109 | LinkIcon, 110 | MoveDownIcon, 111 | MoveUpIcon, 112 | NotificationIcon, 113 | OnboardingIcon, 114 | PasteIcon, 115 | PinIcon, 116 | QRIcon, 117 | RefreshIcon, 118 | ResetIcon, 119 | ScanIcon, 120 | SettingsIcon, 121 | ShareIcon, 122 | SignOutIcon, 123 | SwapIcon, 124 | ThemeIcon, 125 | TrashIcon, 126 | TriangleAlertIcon, 127 | WalletIcon, 128 | XCircleIcon, 129 | XIcon, 130 | ZapIcon, 131 | }; 132 | -------------------------------------------------------------------------------- /components/LinearGradient.tsx: -------------------------------------------------------------------------------- 1 | import { LinearGradient } from "expo-linear-gradient"; 2 | import { cssInterop } from "nativewind"; 3 | 4 | cssInterop(LinearGradient, { 5 | className: { 6 | target: "style", 7 | }, 8 | }); 9 | 10 | export { LinearGradient }; 11 | -------------------------------------------------------------------------------- /components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { ActivityIndicator } from "react-native"; 2 | import { cn } from "~/lib/utils"; 3 | 4 | function Loading({ className }: { className?: string }) { 5 | return ; 6 | } 7 | 8 | export default Loading; 9 | -------------------------------------------------------------------------------- /components/QRCode.tsx: -------------------------------------------------------------------------------- 1 | import { LinearGradient } from "components/LinearGradient"; 2 | import { View } from "react-native"; 3 | import QRCodeLibrary from "react-native-qrcode-svg"; 4 | 5 | function QRCode({ value }: { value: string }) { 6 | return ( 7 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default QRCode; 22 | -------------------------------------------------------------------------------- /components/QRCodeScanner.tsx: -------------------------------------------------------------------------------- 1 | import { useIsFocused } from "@react-navigation/native"; 2 | import { Camera } from "expo-camera"; 3 | import { PermissionStatus } from "expo-modules-core/src/PermissionsInterface"; 4 | import React, { useEffect } from "react"; 5 | import { StyleSheet, View } from "react-native"; 6 | import { CameraOffIcon } from "~/components/Icons"; 7 | import { Text } from "~/components/ui/text"; 8 | import { FocusableCamera } from "./FocusableCamera"; 9 | import Loading from "./Loading"; 10 | 11 | const styles = StyleSheet.create({ 12 | icon: { 13 | width: 64, 14 | height: 64, 15 | }, 16 | }); 17 | 18 | interface QRCodeScannerProps { 19 | onScanned: (data: string) => Promise; 20 | startScanning: boolean; 21 | } 22 | 23 | function QRCodeScanner({ 24 | onScanned, 25 | startScanning = true, 26 | }: QRCodeScannerProps) { 27 | const isFocused = useIsFocused(); 28 | const [isScanning, setScanning] = React.useState(startScanning); 29 | const [isLoading, setLoading] = React.useState(false); 30 | const [permissionStatus, setPermissionStatus] = React.useState( 31 | PermissionStatus.UNDETERMINED, 32 | ); 33 | 34 | useEffect(() => { 35 | // Add some timeout to allow the screen transition to finish before 36 | // starting the camera to avoid stutters 37 | if (startScanning) { 38 | setLoading(true); 39 | window.setTimeout(async () => { 40 | await scan(); 41 | setLoading(false); 42 | }, 200); 43 | } 44 | }, [startScanning]); 45 | 46 | async function scan() { 47 | const { status } = await Camera.requestCameraPermissionsAsync(); 48 | setPermissionStatus(status); 49 | setScanning(status === "granted"); 50 | } 51 | 52 | const handleScanned = async (data: string) => { 53 | if (isScanning) { 54 | console.info(`Bar code with data ${data} has been scanned!`); 55 | const result = await onScanned(data); 56 | setScanning(!result); 57 | } 58 | }; 59 | 60 | return ( 61 | 62 | {(isLoading || 63 | (!isScanning && 64 | permissionStatus === PermissionStatus.UNDETERMINED)) && ( 65 | 66 | 67 | 68 | )} 69 | {!isLoading && ( 70 | <> 71 | {!isScanning && permissionStatus === PermissionStatus.DENIED && ( 72 | 73 | 74 | 75 | Camera Permission Denied 76 | 77 | 78 | It seems you denied permissions to use your camera. You might 79 | need to go to your device settings to allow access to your 80 | camera again. 81 | 82 | 83 | )} 84 | {isScanning && isFocused && ( 85 | 86 | )} 87 | 88 | )} 89 | 90 | ); 91 | } 92 | 93 | export default QRCodeScanner; 94 | -------------------------------------------------------------------------------- /components/Receiver.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View } from "react-native"; 3 | import { Text } from "~/components/ui/text"; 4 | 5 | interface ReceiverProps { 6 | lightningAddress?: string; 7 | } 8 | 9 | export function Receiver({ lightningAddress }: ReceiverProps) { 10 | const shouldShowReceiver = 11 | lightningAddress && 12 | lightningAddress.toLowerCase().replace("lightning:", "").includes("@"); 13 | 14 | if (!shouldShowReceiver) { 15 | return null; 16 | } 17 | 18 | return ( 19 | 20 | 21 | To 22 | 23 | 24 | {lightningAddress.toLowerCase().replace("lightning:", "")} 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/Screen.tsx: -------------------------------------------------------------------------------- 1 | import { NativeStackHeaderRightProps } from "@react-navigation/native-stack/src/types"; 2 | import { Stack } from "expo-router"; 3 | import { StackAnimationTypes } from "react-native-screens"; 4 | 5 | type ScreenProps = { 6 | title: string; 7 | right?: (props: NativeStackHeaderRightProps) => React.ReactNode; 8 | animation?: StackAnimationTypes; 9 | }; 10 | 11 | function Screen({ title, animation, right }: ScreenProps) { 12 | return ( 13 | 21 | ); 22 | } 23 | 24 | export default Screen; 25 | -------------------------------------------------------------------------------- /components/ToastConfig.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "expo-router"; 2 | import { View } from "react-native"; 3 | import { ToastConfig } from "react-native-toast-message"; 4 | import { CheckCircleIcon, XCircleIcon } from "~/components/Icons"; 5 | import { Button } from "./ui/button"; 6 | import { Text } from "./ui/text"; 7 | 8 | export const toastConfig: ToastConfig = { 9 | success: ({ text1, text2 }) => ( 10 | 11 | 12 | 13 | {text1} 14 | 15 | {text2 && {text2}} 16 | 17 | ), 18 | info: ({ text1, text2, hide }) => ( 19 | 20 | 21 | 22 | {text1} 23 | 24 | {text2 && {text2}} 25 | 26 | ), 27 | error: ({ text1, text2, hide }) => ( 28 | 29 | 30 | 31 | {text1} 32 | 33 | {text2 && {text2}} 34 | 35 | ), 36 | connectionError: ({ text1, text2, hide }) => { 37 | return ( 38 | 39 | 40 | 41 | {text1} 42 | 43 | 44 | 47 | 48 | 49 | ); 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /components/TransactionItem.tsx: -------------------------------------------------------------------------------- 1 | import { Nip47Transaction } from "@getalby/sdk/dist/nwc"; 2 | import dayjs from "dayjs"; 3 | import { router } from "expo-router"; 4 | import React from "react"; 5 | import { Pressable, View } from "react-native"; 6 | import FailedTransactionIcon from "~/components/icons/FailedTransaction"; 7 | import PendingTransactionIcon from "~/components/icons/PendingTransaction"; 8 | import ReceivedTransactionIcon from "~/components/icons/ReceivedTransaction"; 9 | import SentTransactionIcon from "~/components/icons/SentTransaction"; 10 | import { Text } from "~/components/ui/text"; 11 | import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; 12 | import { cn, safeNpubEncode } from "~/lib/utils"; 13 | 14 | type Props = { 15 | tx: Nip47Transaction; 16 | }; 17 | 18 | export function TransactionItem({ tx }: Props) { 19 | const metadata = tx.metadata; 20 | const type = tx.type; 21 | const getFiatAmount = useGetFiatAmount(); 22 | 23 | const typeStateText = 24 | type === "incoming" 25 | ? "Received" 26 | : tx.state === "settled" // we only fetch settled incoming payments 27 | ? "Sent" 28 | : tx.state === "pending" 29 | ? "Sending" 30 | : "Failed"; 31 | 32 | const Icon = 33 | tx.state === "failed" 34 | ? FailedTransactionIcon 35 | : tx.state === "pending" 36 | ? PendingTransactionIcon 37 | : tx.type === "outgoing" 38 | ? SentTransactionIcon 39 | : ReceivedTransactionIcon; 40 | 41 | const pubkey = tx.metadata?.nostr?.pubkey; 42 | const npub = pubkey ? safeNpubEncode(pubkey) : undefined; 43 | 44 | const payerName = tx.metadata?.payer_data?.name; 45 | const from = payerName 46 | ? `from ${payerName}` 47 | : npub 48 | ? `zap from ${npub.substring(0, 12)}...` 49 | : undefined; 50 | 51 | const recipientIdentifier = tx.metadata?.recipient_data?.identifier; 52 | const to = recipientIdentifier 53 | ? `${tx.state === "failed" ? "payment " : ""}to ${recipientIdentifier}` 54 | : undefined; 55 | 56 | return ( 57 | 60 | router.navigate({ 61 | pathname: "/transaction", 62 | params: { 63 | transactionJSON: encodeURIComponent(JSON.stringify(tx)), 64 | }, 65 | }) 66 | } 67 | > 68 | 74 | 75 | 76 | 77 | 78 | 79 | 83 | {typeStateText} 84 | {from !== undefined && <> {from}} 85 | {to !== undefined && <> {to}} 86 | 87 | 88 | {dayjs.unix(tx.settled_at || tx.created_at).fromNow()} 89 | 90 | 91 | {(tx.description || metadata?.comment) && ( 92 | {tx.description || metadata?.comment} 93 | )} 94 | 95 | 96 | 102 | {tx.type === "incoming" ? "+" : "-"}{" "} 103 | {new Intl.NumberFormat().format(Math.floor(tx.amount / 1000))} 104 | 105 | {" "} 106 | {Math.floor(tx.amount / 1000) === 1 ? "sat" : "sats"} 107 | 108 | 109 | 110 | {getFiatAmount && getFiatAmount(Math.floor(tx.amount / 1000))} 111 | 112 | 113 | 114 | 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /components/WalletSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BottomSheetBackdrop, 3 | BottomSheetBackdropProps, 4 | BottomSheetModal, 5 | BottomSheetView, 6 | } from "@gorhom/bottom-sheet"; 7 | import React, { useCallback, useMemo, useRef } from "react"; 8 | import { FlatList, TouchableOpacity, View } from "react-native"; 9 | import Toast from "react-native-toast-message"; 10 | import { ChevronDownIcon, WalletIcon } from "~/components/Icons"; 11 | import { Text } from "~/components/ui/text"; 12 | import { DEFAULT_WALLET_NAME } from "~/lib/constants"; 13 | import { useAppStore, Wallet } from "~/lib/state/appStore"; 14 | import { useColorScheme } from "~/lib/useColorScheme"; 15 | import { cn } from "~/lib/utils"; 16 | 17 | interface WalletSwitcherProps { 18 | selectedWalletId: number; 19 | wallets: Wallet[]; 20 | } 21 | 22 | export function WalletSwitcher({ 23 | selectedWalletId, 24 | wallets, 25 | }: WalletSwitcherProps) { 26 | const { isDarkColorScheme } = useColorScheme(); 27 | const bottomSheetModalRef = useRef(null); 28 | 29 | const openSheet = useCallback(() => { 30 | if (wallets.length > 1) { 31 | bottomSheetModalRef.current?.present(); 32 | } 33 | }, [wallets.length]); 34 | 35 | const selectedWallet = useMemo( 36 | () => wallets.find((w, i) => i === selectedWalletId), 37 | [wallets, selectedWalletId], 38 | ); 39 | 40 | const renderBackdrop = useCallback( 41 | (props: BottomSheetBackdropProps) => ( 42 | 50 | ), 51 | [isDarkColorScheme], 52 | ); 53 | return ( 54 | <> 55 | 59 | 60 | 65 | {selectedWallet?.name || DEFAULT_WALLET_NAME} 66 | 67 | {wallets.length > 1 && ( 68 | 69 | )} 70 | 71 | 72 | 83 | 84 | 85 | Switch Wallet 86 | 87 | { 90 | const active = index === selectedWalletId; 91 | 92 | return ( 93 | { 95 | if (index !== selectedWalletId) { 96 | useAppStore.getState().setSelectedWalletId(index); 97 | Toast.show({ 98 | type: "success", 99 | text1: `Switched wallet to ${wallet.name || DEFAULT_WALLET_NAME}`, 100 | position: "top", 101 | }); 102 | bottomSheetModalRef.current?.dismiss(); 103 | } 104 | }} 105 | className={cn( 106 | "flex flex-row items-center justify-between p-6 rounded-2xl border-2", 107 | active ? "border-primary" : "border-transparent", 108 | )} 109 | > 110 | 111 | 112 | 120 | {wallet.name || DEFAULT_WALLET_NAME} 121 | 122 | 123 | 124 | ); 125 | }} 126 | /> 127 | 128 | 129 | 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /components/icons/FailedTransaction.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useColorScheme } from "react-native"; 3 | import Svg, { Path, Rect, SvgProps } from "react-native-svg"; 4 | 5 | const FailedTransactionIcon = (props: SvgProps) => { 6 | const colorScheme = useColorScheme(); 7 | 8 | const colors = { 9 | light: { 10 | rectFill: "#FEE2E2", 11 | pathStroke: "#EF4444", 12 | }, 13 | dark: { 14 | rectFill: "#4C0519", 15 | pathStroke: "#F43F5E", 16 | }, 17 | }; 18 | 19 | const currentColors = colorScheme === "dark" ? colors.dark : colors.light; 20 | 21 | return ( 22 | 23 | 24 | 31 | 32 | ); 33 | }; 34 | 35 | export default FailedTransactionIcon; 36 | -------------------------------------------------------------------------------- /components/icons/LargeArrowDown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { Path, SvgProps } from "react-native-svg"; 3 | const LargeArrowDown = (props: SvgProps) => ( 4 | 5 | 11 | 12 | ); 13 | export default LargeArrowDown; 14 | -------------------------------------------------------------------------------- /components/icons/LargeArrowUp.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { Path, SvgProps } from "react-native-svg"; 3 | const LargeArrowUp = (props: SvgProps) => ( 4 | 5 | 11 | 12 | ); 13 | 14 | export default LargeArrowUp; 15 | -------------------------------------------------------------------------------- /components/icons/NWCIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useColorScheme } from "react-native"; 3 | import Svg, { Path, SvgProps } from "react-native-svg"; 4 | 5 | const NWCIcon = (props: SvgProps) => { 6 | const colorScheme = useColorScheme(); 7 | 8 | // hex values of text-muted-foreground 9 | const colors = { 10 | light: "#71717A", 11 | dark: "#A1A1AA", 12 | }; 13 | 14 | const color = colorScheme === "dark" ? colors.dark : colors.light; 15 | 16 | return ( 17 | 18 | 22 | 26 | 27 | ); 28 | }; 29 | 30 | export default NWCIcon; 31 | -------------------------------------------------------------------------------- /components/icons/PendingTransaction.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useColorScheme } from "react-native"; 3 | import Svg, { Path, Rect, SvgProps } from "react-native-svg"; 4 | 5 | const PendingTransactionIcon = (props: SvgProps) => { 6 | const colorScheme = useColorScheme(); 7 | 8 | const colors = { 9 | light: { 10 | rectFill: "#DBEAFE", 11 | pathStroke: "#3B82F6", 12 | }, 13 | dark: { 14 | rectFill: "#082F49", 15 | pathStroke: "#0EA5E9", 16 | }, 17 | }; 18 | 19 | const currentColors = colorScheme === "dark" ? colors.dark : colors.light; 20 | 21 | return ( 22 | 23 | 24 | 31 | 32 | ); 33 | }; 34 | 35 | export default PendingTransactionIcon; 36 | -------------------------------------------------------------------------------- /components/icons/ReceivedTransaction.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useColorScheme } from "react-native"; 3 | import Svg, { Path, Rect, SvgProps } from "react-native-svg"; 4 | 5 | const ReceivedTransactionIcon = (props: SvgProps) => { 6 | const colorScheme = useColorScheme(); 7 | 8 | const colors = { 9 | light: { 10 | rectFill: "#DCFCE7", 11 | pathStroke: "#22C55E", 12 | }, 13 | dark: { 14 | rectFill: "#022C22", 15 | pathStroke: "#14B8A6", 16 | }, 17 | }; 18 | 19 | const currentColors = colorScheme === "dark" ? colors.dark : colors.light; 20 | 21 | return ( 22 | 23 | 24 | 31 | 32 | ); 33 | }; 34 | 35 | export default ReceivedTransactionIcon; 36 | -------------------------------------------------------------------------------- /components/icons/RedeemIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useColorScheme } from "react-native"; 3 | import Svg, { Path, SvgProps } from "react-native-svg"; 4 | 5 | // TODO: Replace with PopiconsWithdrawalSolid once fixed in @popicons/react-native 6 | const RedeemIcon = (props: SvgProps) => { 7 | const colorScheme = useColorScheme(); 8 | 9 | // hex values of text-muted-foreground 10 | const colors = { 11 | light: "#71717A", 12 | dark: "#A1A1AA", 13 | }; 14 | 15 | const color = colorScheme === "dark" ? colors.dark : colors.light; 16 | 17 | return ( 18 | 19 | 25 | 26 | ); 27 | }; 28 | 29 | export default RedeemIcon; 30 | -------------------------------------------------------------------------------- /components/icons/SentTransaction.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useColorScheme } from "react-native"; 3 | import Svg, { Path, Rect, SvgProps } from "react-native-svg"; 4 | 5 | const SentTransactionIcon = (props: SvgProps) => { 6 | const colorScheme = useColorScheme(); 7 | 8 | const colors = { 9 | light: { 10 | rectFill: "#FFEDD5", 11 | pathStroke: "#F97316", 12 | }, 13 | dark: { 14 | rectFill: "#451A03", 15 | pathStroke: "#F59E0B", 16 | }, 17 | }; 18 | 19 | const currentColors = colorScheme === "dark" ? colors.dark : colors.light; 20 | 21 | return ( 22 | 23 | 24 | 31 | 32 | ); 33 | }; 34 | 35 | export default SentTransactionIcon; 36 | -------------------------------------------------------------------------------- /components/primitives/label/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Pressable, Text as RNText } from "react-native"; 3 | import * as Slot from "~/components/primitives/slot"; 4 | import type { 5 | PressableRef, 6 | SlottablePressableProps, 7 | SlottableTextProps, 8 | TextRef, 9 | } from "~/components/primitives/types"; 10 | import type { LabelRootProps, LabelTextProps } from "./types"; 11 | 12 | const Root = React.forwardRef< 13 | PressableRef, 14 | Omit & 15 | LabelRootProps 16 | >(({ asChild, ...props }, ref) => { 17 | const Component = asChild ? Slot.Pressable : Pressable; 18 | return ; 19 | }); 20 | 21 | Root.displayName = "RootNativeLabel"; 22 | 23 | const Text = React.forwardRef( 24 | ({ asChild, ...props }, ref) => { 25 | const Component = asChild ? Slot.Text : RNText; 26 | return ; 27 | }, 28 | ); 29 | 30 | Text.displayName = "TextNativeLabel"; 31 | 32 | export { Root, Text }; 33 | -------------------------------------------------------------------------------- /components/primitives/label/types.ts: -------------------------------------------------------------------------------- 1 | import type { ViewStyle } from "react-native"; 2 | 3 | interface LabelRootProps { 4 | children: React.ReactNode; 5 | style?: ViewStyle; 6 | } 7 | 8 | interface LabelTextProps { 9 | /** 10 | * Equivalent to `id` so that the same value can be passed as `aria-labelledby` to the input element. 11 | */ 12 | nativeID: string; 13 | } 14 | 15 | export type { LabelRootProps, LabelTextProps }; 16 | -------------------------------------------------------------------------------- /components/primitives/types.ts: -------------------------------------------------------------------------------- 1 | import type { Pressable, Text, View, ViewStyle } from "react-native"; 2 | 3 | type ComponentPropsWithAsChild> = 4 | React.ComponentPropsWithoutRef & { asChild?: boolean }; 5 | 6 | type ViewRef = React.ElementRef; 7 | type PressableRef = React.ElementRef; 8 | type TextRef = React.ElementRef; 9 | 10 | type SlottableViewProps = ComponentPropsWithAsChild; 11 | type SlottablePressableProps = ComponentPropsWithAsChild & { 12 | /** 13 | * Platform: WEB ONLY 14 | */ 15 | onKeyDown?: (ev: React.KeyboardEvent) => void; 16 | /** 17 | * Platform: WEB ONLY 18 | */ 19 | onKeyUp?: (ev: React.KeyboardEvent) => void; 20 | }; 21 | type SlottableTextProps = ComponentPropsWithAsChild; 22 | 23 | interface Insets { 24 | top?: number; 25 | bottom?: number; 26 | left?: number; 27 | right?: number; 28 | } 29 | 30 | type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>; 31 | type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>; 32 | 33 | /** 34 | * Certain props are only available on the native version of the component. 35 | * @docs For the web version, see the Radix documentation https://www.radix-ui.com/primitives 36 | */ 37 | interface PositionedContentProps { 38 | forceMount?: true | undefined; 39 | style?: ViewStyle; 40 | alignOffset?: number; 41 | insets?: Insets; 42 | avoidCollisions?: boolean; 43 | align?: "start" | "center" | "end"; 44 | side?: "top" | "bottom"; 45 | sideOffset?: number; 46 | /** 47 | * Platform: NATIVE ONLY 48 | */ 49 | disablePositioningStyle?: boolean; 50 | /** 51 | * Platform: WEB ONLY 52 | */ 53 | loop?: boolean; 54 | /** 55 | * Platform: WEB ONLY 56 | */ 57 | onCloseAutoFocus?: (event: Event) => void; 58 | /** 59 | * Platform: WEB ONLY 60 | */ 61 | onEscapeKeyDown?: (event: KeyboardEvent) => void; 62 | /** 63 | * Platform: WEB ONLY 64 | */ 65 | onPointerDownOutside?: (event: PointerDownOutsideEvent) => void; 66 | /** 67 | * Platform: WEB ONLY 68 | */ 69 | onFocusOutside?: (event: FocusOutsideEvent) => void; 70 | /** 71 | * Platform: WEB ONLY 72 | */ 73 | onInteractOutside?: ( 74 | event: PointerDownOutsideEvent | FocusOutsideEvent, 75 | ) => void; 76 | /** 77 | * Platform: WEB ONLY 78 | */ 79 | collisionBoundary?: Element | null | (Element | null)[]; 80 | /** 81 | * Platform: WEB ONLY 82 | */ 83 | sticky?: "partial" | "always"; 84 | /** 85 | * Platform: WEB ONLY 86 | */ 87 | hideWhenDetached?: boolean; 88 | } 89 | 90 | interface ForceMountable { 91 | forceMount?: true | undefined; 92 | } 93 | 94 | export type { 95 | ComponentPropsWithAsChild, 96 | ForceMountable, 97 | Insets, 98 | PositionedContentProps, 99 | PressableRef, 100 | SlottablePressableProps, 101 | SlottableTextProps, 102 | SlottableViewProps, 103 | TextRef, 104 | ViewRef, 105 | }; 106 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | import { LinearGradient } from "expo-linear-gradient"; 3 | import * as React from "react"; 4 | import { Platform, Pressable, StyleSheet, View } from "react-native"; 5 | import { TextClassContext } from "~/components/ui/text"; 6 | import { cn } from "~/lib/utils"; 7 | 8 | const buttonVariants = cva( 9 | "group flex items-center justify-center rounded-lg web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2", 10 | { 11 | variants: { 12 | variant: { 13 | default: "web:hover:opacity-90 active:opacity-90", 14 | destructive: "bg-destructive web:hover:opacity-90 active:opacity-90", 15 | outline: 16 | "border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent", 17 | secondary: "bg-secondary web:hover:opacity-80 active:opacity-80", 18 | ghost: 19 | "web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent", 20 | link: "web:underline-offset-4 web:hover:underline web:focus:underline ", 21 | }, 22 | size: { 23 | default: "min-h-10 px-4 py-2 native:min-h-12 native:px-3 native:py-3", 24 | sm: "min-h-9 rounded-md px-3", 25 | lg: "min-h-11 rounded-2xl px-8 native:min-h-16", 26 | icon: "min-h-10 min-w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | const buttonTextVariants = cva( 37 | "web:whitespace-nowrap text-lg text-foreground web:transition-colors leading-6", 38 | { 39 | variants: { 40 | variant: { 41 | default: "text-primary-foreground font-bold2", 42 | destructive: "text-destructive-foreground", 43 | outline: "group-active:text-accent-foreground", 44 | secondary: 45 | "text-secondary-foreground group-active:text-secondary-foreground", 46 | ghost: "group-active:text-accent-foreground", 47 | link: "text-primary group-active:underline", 48 | }, 49 | size: { 50 | default: "font-medium2", 51 | sm: "", 52 | lg: "native:text-2xl font-bold2", 53 | icon: "", 54 | }, 55 | }, 56 | defaultVariants: { 57 | variant: "default", 58 | size: "default", 59 | }, 60 | }, 61 | ); 62 | 63 | type ButtonProps = React.ComponentPropsWithoutRef & 64 | VariantProps; 65 | 66 | const Button = React.forwardRef< 67 | React.ElementRef, 68 | ButtonProps 69 | >(({ className, variant, size, ...props }, ref) => { 70 | return ( 71 | 77 | {!variant || variant === "default" ? ( 78 | 87 | 93 | 102 | 103 | 104 | ) : ( 105 | 115 | )} 116 | 117 | ); 118 | }); 119 | Button.displayName = "Button"; 120 | 121 | export { Button, buttonTextVariants, buttonVariants }; 122 | export type { ButtonProps }; 123 | 124 | const shadows = StyleSheet.create({ 125 | small: { 126 | ...Platform.select({ 127 | // make sure bg color is applied to avoid RCTView errors 128 | ios: { 129 | shadowColor: "black", 130 | shadowOpacity: 0.15, 131 | shadowOffset: { 132 | width: 0, 133 | height: 2, 134 | }, 135 | shadowRadius: 2, 136 | }, 137 | android: { 138 | elevation: 2, 139 | }, 140 | }), 141 | }, 142 | }); 143 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Text, View } from "react-native"; 3 | import { TextRef, ViewRef } from "~/components/primitives/types"; 4 | import { TextClassContext } from "~/components/ui/text"; 5 | import { cn } from "~/lib/utils"; 6 | 7 | const Card = React.forwardRef< 8 | ViewRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | )); 20 | Card.displayName = "Card"; 21 | 22 | const CardHeader = React.forwardRef< 23 | ViewRef, 24 | React.ComponentPropsWithoutRef 25 | >(({ className, ...props }, ref) => ( 26 | 31 | )); 32 | CardHeader.displayName = "CardHeader"; 33 | 34 | const CardTitle = React.forwardRef< 35 | TextRef, 36 | React.ComponentPropsWithoutRef 37 | >(({ className, ...props }, ref) => ( 38 | 49 | )); 50 | CardTitle.displayName = "CardTitle"; 51 | 52 | const CardDescription = React.forwardRef< 53 | TextRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 61 | )); 62 | CardDescription.displayName = "CardDescription"; 63 | 64 | const CardContent = React.forwardRef< 65 | ViewRef, 66 | React.ComponentPropsWithoutRef 67 | >(({ className, ...props }, ref) => ( 68 | 69 | 70 | 71 | )); 72 | CardContent.displayName = "CardContent"; 73 | 74 | const CardFooter = React.forwardRef< 75 | ViewRef, 76 | React.ComponentPropsWithoutRef 77 | >(({ className, ...props }, ref) => ( 78 | 83 | )); 84 | CardFooter.displayName = "CardFooter"; 85 | 86 | export { 87 | Card, 88 | CardContent, 89 | CardDescription, 90 | CardFooter, 91 | CardHeader, 92 | CardTitle, 93 | }; 94 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { TextInput } from "react-native"; 3 | import { CURSOR_COLOR } from "~/lib/constants"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | const Input = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, placeholderClassName, ...props }, ref) => { 11 | return ( 12 | 23 | ); 24 | }); 25 | 26 | Input.displayName = "Input"; 27 | 28 | export { Input }; 29 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "~/components/primitives/label"; 3 | import { cn } from "~/lib/utils"; 4 | 5 | const Label = React.forwardRef< 6 | React.ElementRef, 7 | React.ComponentPropsWithoutRef 8 | >( 9 | ( 10 | { className, onPress, onLongPress, onPressIn, onPressOut, ...props }, 11 | ref, 12 | ) => ( 13 | 20 | 28 | 29 | ), 30 | ); 31 | Label.displayName = LabelPrimitive.Root.displayName; 32 | 33 | export { Label }; 34 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Animated, Easing, TextProps } from "react-native"; 3 | import { cn } from "~/lib/utils"; 4 | 5 | const duration = 1000; 6 | 7 | function Skeleton({ className, ...props }: Omit) { 8 | const opacity = React.useRef(new Animated.Value(1)).current; 9 | 10 | React.useEffect(() => { 11 | const pulse = Animated.loop( 12 | Animated.sequence([ 13 | Animated.timing(opacity, { 14 | toValue: 0.5, 15 | duration, 16 | easing: Easing.linear, 17 | useNativeDriver: true, 18 | }), 19 | Animated.timing(opacity, { 20 | toValue: 1, 21 | duration, 22 | easing: Easing.linear, 23 | useNativeDriver: true, 24 | }), 25 | ]), 26 | ); 27 | pulse.start(); 28 | return () => pulse.stop(); 29 | }, [opacity]); 30 | 31 | return ( 32 | 37 | ); 38 | } 39 | 40 | export { Skeleton }; 41 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as SwitchPrimitives from "@rn-primitives/switch"; 2 | import * as React from "react"; 3 | import { Platform } from "react-native"; 4 | import Animated, { 5 | interpolateColor, 6 | useAnimatedStyle, 7 | useDerivedValue, 8 | withTiming, 9 | } from "react-native-reanimated"; 10 | import { useColorScheme } from "~/lib/useColorScheme"; 11 | import { cn } from "~/lib/utils"; 12 | 13 | const SwitchWeb = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 27 | 33 | 34 | )); 35 | 36 | SwitchWeb.displayName = "SwitchWeb"; 37 | 38 | const RGB_COLORS = { 39 | light: { 40 | primary: "rgb(255, 224, 112)", 41 | input: "rgb(228, 228, 231)", 42 | }, 43 | dark: { 44 | primary: "rgb(255, 224, 112)", 45 | input: "rgb(228, 228, 231)", 46 | }, 47 | } as const; 48 | 49 | const SwitchNative = React.forwardRef< 50 | React.ElementRef, 51 | React.ComponentPropsWithoutRef 52 | >(({ className, ...props }, ref) => { 53 | const { colorScheme } = useColorScheme(); 54 | const translateX = useDerivedValue(() => (props.checked ? 18 : 0)); 55 | const animatedRootStyle = useAnimatedStyle(() => { 56 | return { 57 | backgroundColor: interpolateColor( 58 | Number(props.checked), 59 | [0, 1], 60 | [RGB_COLORS[colorScheme].input, RGB_COLORS[colorScheme].primary], 61 | ), 62 | }; 63 | }); 64 | const animatedThumbStyle = useAnimatedStyle(() => ({ 65 | transform: [ 66 | { translateX: withTiming(translateX.value, { duration: 200 }) }, 67 | ], 68 | })); 69 | return ( 70 | 77 | 85 | 86 | 91 | 92 | 93 | 94 | ); 95 | }); 96 | SwitchNative.displayName = "SwitchNative"; 97 | 98 | const Switch = Platform.select({ 99 | web: SwitchWeb, 100 | default: SwitchNative, 101 | }); 102 | 103 | export { Switch }; 104 | -------------------------------------------------------------------------------- /components/ui/text.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Text as RNText } from "react-native"; 3 | import * as Slot from "~/components/primitives/slot"; 4 | import { SlottableTextProps, TextRef } from "~/components/primitives/types"; 5 | import { cn } from "~/lib/utils"; 6 | 7 | const TextClassContext = React.createContext(undefined); 8 | 9 | const Text = React.forwardRef( 10 | ({ className, asChild = false, ...props }, ref) => { 11 | const textClass = React.useContext(TextClassContext); 12 | const Component = asChild ? Slot.Text : RNText; 13 | return ( 14 | 23 | ); 24 | }, 25 | ); 26 | Text.displayName = "Text"; 27 | 28 | export { Text, TextClassContext }; 29 | -------------------------------------------------------------------------------- /context/Notification.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { IS_EXPO_GO } from "~/lib/constants"; 3 | import { handleLink } from "~/lib/link"; 4 | import { useAppStore } from "~/lib/state/appStore"; 5 | 6 | let ExpoNotifications: any; 7 | 8 | if (!IS_EXPO_GO) { 9 | ExpoNotifications = require("expo-notifications"); 10 | 11 | ExpoNotifications.setNotificationHandler({ 12 | handleNotification: async () => { 13 | return { 14 | shouldShowAlert: true, 15 | shouldPlaySound: true, 16 | shouldSetBadge: false, 17 | }; 18 | }, 19 | }); 20 | } 21 | 22 | export const NotificationProvider = ({ children }: any) => { 23 | const responseListener = useRef(); 24 | const isNotificationsEnabled = useAppStore( 25 | (store) => store.isNotificationsEnabled, 26 | ); 27 | 28 | useEffect(() => { 29 | if (IS_EXPO_GO || !isNotificationsEnabled) { 30 | return; 31 | } 32 | 33 | // this is for iOS only as tapping the notifications 34 | // directly open the deep link on android 35 | responseListener.current = 36 | ExpoNotifications.addNotificationResponseReceivedListener( 37 | (response: any) => { 38 | const deepLink = response.notification.request.content.data.deepLink; 39 | if (deepLink) { 40 | handleLink(deepLink); 41 | } 42 | }, 43 | ); 44 | 45 | return () => { 46 | responseListener.current && 47 | ExpoNotifications.removeNotificationSubscription( 48 | responseListener.current, 49 | ); 50 | }; 51 | }, [isNotificationsEnabled]); 52 | 53 | return children; 54 | }; 55 | -------------------------------------------------------------------------------- /context/UserInactivity.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { AppState, AppStateStatus } from "react-native"; 3 | import { INACTIVITY_THRESHOLD } from "~/lib/constants"; 4 | import { useAppStore } from "~/lib/state/appStore"; 5 | 6 | let lastActiveTime = 0; 7 | 8 | export const UserInactivityProvider = ({ children }: any) => { 9 | const [appState, setAppState] = React.useState( 10 | AppState.currentState, 11 | ); 12 | const isSecurityEnabled = useAppStore((store) => store.isSecurityEnabled); 13 | 14 | const handleAppStateChange = React.useCallback( 15 | async (nextState: AppStateStatus): Promise => { 16 | const now = Date.now(); 17 | useAppStore.getState().setLastAppStateChangeTime(now); 18 | if (isSecurityEnabled) { 19 | if (appState === "active" && nextState.match(/inactive|background/)) { 20 | lastActiveTime = now; 21 | } else if ( 22 | appState.match(/inactive|background/) && 23 | nextState === "active" 24 | ) { 25 | if (lastActiveTime) { 26 | const timeElapsed = Date.now() - lastActiveTime; 27 | if (timeElapsed >= INACTIVITY_THRESHOLD) { 28 | useAppStore.getState().setUnlocked(false); 29 | } 30 | } 31 | } 32 | } 33 | setAppState(nextState); 34 | }, 35 | [appState, isSecurityEnabled], 36 | ); 37 | 38 | React.useEffect(() => { 39 | const subscription = AppState.addEventListener( 40 | "change", 41 | handleAppStateChange, 42 | ); 43 | 44 | return () => { 45 | subscription.remove(); 46 | }; 47 | }, [appState, handleAppStateChange, isSecurityEnabled]); 48 | 49 | return children; 50 | }; 51 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 5.4.0", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "development": { 8 | "developmentClient": true, 9 | "distribution": "internal" 10 | }, 11 | "preview": { 12 | "distribution": "internal" 13 | }, 14 | "production": { 15 | "autoIncrement": true 16 | }, 17 | "production_apk": { 18 | "autoIncrement": true, 19 | "android": { 20 | "buildType": "apk" 21 | } 22 | } 23 | }, 24 | "submit": { 25 | "production": {} 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 215 28% 17%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 47 100% 50%; 14 | --primary-foreground: 217 19% 27%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 217 19% 27%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 5.9% 10%; 26 | } 27 | 28 | .dark:root { 29 | --background: 240 10% 3.9%; 30 | --foreground: 0 0% 98%; 31 | --card: 240 10% 3.9%; 32 | --card-foreground: 0 0% 98%; 33 | --popover: 240 10% 3.9%; 34 | --popover-foreground: 0 0% 98%; 35 | --primary: 47 100% 50%; 36 | --primary-foreground: 217 19% 27%; 37 | --secondary: 240 3.7% 15.9%; 38 | --secondary-foreground: 0 0% 98%; 39 | --muted: 240 3.7% 15.9%; 40 | --muted-foreground: 240 5% 64.9%; 41 | --accent: 240 3.7% 15.9%; 42 | --accent-foreground: 0 0% 98%; 43 | --destructive: 0 72% 51%; 44 | --destructive-foreground: 0 0% 98%; 45 | --border: 240 3.7% 15.9%; 46 | --input: 240 3.7% 15.9%; 47 | --ring: 240 4.9% 83.9%; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /hooks/__tests__/useHandleLinking.ts: -------------------------------------------------------------------------------- 1 | import { router } from "expo-router"; 2 | import { handleLink } from "../../lib/link"; 3 | 4 | jest.mock("expo-router"); 5 | 6 | const mockLNURLPayResponse = { 7 | tag: "payRequest", 8 | callback: "https://getalby.com/callback", 9 | commentAllowed: 255, 10 | minSendable: 1000, 11 | maxSendable: 10000000, 12 | payerData: { 13 | name: { mandatory: false }, 14 | email: { mandatory: false }, 15 | pubkey: { mandatory: false }, 16 | }, 17 | }; 18 | 19 | const mockLNURLWithdrawResponse = { 20 | tag: "withdrawRequest", 21 | callback: "https://getalby.com/callback", 22 | k1: "unused", 23 | defaultDescription: "withdrawal", 24 | minWithdrawable: 21000, 25 | maxWithdrawable: 21000, 26 | }; 27 | 28 | // Mock the lnurl module 29 | jest.mock("../../lib/lnurl", () => { 30 | const originalModule = jest.requireActual("../../lib/lnurl"); 31 | 32 | const mockGetDetails = jest.fn(async (lnurlString) => { 33 | if (lnurlString === "hello@getalby.com") { 34 | return mockLNURLPayResponse; 35 | } 36 | if (lnurlString.startsWith("lnurlw")) { 37 | return mockLNURLWithdrawResponse; 38 | } 39 | return originalModule.lnurl.getDetails(lnurlString); 40 | }); 41 | 42 | return { 43 | ...originalModule, 44 | lnurl: { 45 | ...originalModule.lnurl, 46 | getDetails: mockGetDetails, 47 | }, 48 | }; 49 | }); 50 | 51 | const testVectors: Record = { 52 | // Lightning Addresses 53 | "lightning:hello@getalby.com": { 54 | path: "/send/lnurl-pay", 55 | params: { 56 | lnurlDetailsJSON: JSON.stringify(mockLNURLPayResponse), 57 | receiver: "hello@getalby.com", 58 | }, 59 | }, 60 | "lightning://hello@getalby.com": { 61 | path: "/send/lnurl-pay", 62 | params: { 63 | lnurlDetailsJSON: JSON.stringify(mockLNURLPayResponse), 64 | receiver: "hello@getalby.com", 65 | }, 66 | }, 67 | "LIGHTNING://hello@getalby.com": { 68 | path: "/send/lnurl-pay", 69 | params: { 70 | lnurlDetailsJSON: JSON.stringify(mockLNURLPayResponse), 71 | receiver: "hello@getalby.com", 72 | }, 73 | }, 74 | "LIGHTNING:hello@getalby.com": { 75 | path: "/send/lnurl-pay", 76 | params: { 77 | lnurlDetailsJSON: JSON.stringify(mockLNURLPayResponse), 78 | receiver: "hello@getalby.com", 79 | }, 80 | }, 81 | 82 | // Lightning invoices 83 | "lightning:lnbc123": { 84 | path: "/send", 85 | params: { url: "lnbc123" }, 86 | }, 87 | "lightning://lnbc123": { 88 | path: "/send", 89 | params: { url: "lnbc123" }, 90 | }, 91 | 92 | // BIP21 93 | "bitcoin:bitcoinaddress?lightning=lnbc123": { 94 | path: "/send", 95 | params: { url: "lnbc123" }, 96 | }, 97 | "BITCOIN:bitcoinaddress?lightning=lnbc123": { 98 | path: "/send", 99 | params: { url: "lnbc123" }, 100 | }, 101 | 102 | // LNURL-withdraw 103 | "lightning:lnurlw123": { 104 | path: "/withdraw", 105 | params: { url: "lnurlw123" }, 106 | }, 107 | "lightning://lnurlw123": { 108 | path: "/withdraw", 109 | params: { url: "lnurlw123" }, 110 | }, 111 | }; 112 | 113 | describe("handleLink", () => { 114 | beforeEach(() => { 115 | jest.clearAllMocks(); 116 | }); 117 | 118 | it("should return early if url is empty", async () => { 119 | await handleLink(""); 120 | expect(router.push).not.toHaveBeenCalled(); 121 | expect(router.replace).not.toHaveBeenCalled(); 122 | }); 123 | 124 | it("should return early if scheme is not supported", async () => { 125 | await handleLink("mailto:hello@getalby.com"); 126 | expect(router.replace).toHaveBeenCalledWith({ 127 | pathname: "/", 128 | }); 129 | expect(router.push).not.toHaveBeenCalled(); 130 | }); 131 | 132 | describe("Expo links", () => { 133 | test.each(Object.entries(testVectors))( 134 | "should parse the URL '%s' and navigate correctly", 135 | async (url, expectedOutput) => { 136 | await handleLink("exp://127.0.0.1:8081/--/" + url); 137 | assertRedirect(expectedOutput.path, expectedOutput.params); 138 | }, 139 | ); 140 | }); 141 | 142 | describe("Production links", () => { 143 | test.each(Object.entries(testVectors))( 144 | "should parse the URL '%s' and navigate correctly", 145 | async (url, expectedOutput) => { 146 | await handleLink(url); 147 | assertRedirect(expectedOutput.path, expectedOutput.params); 148 | }, 149 | ); 150 | }); 151 | }); 152 | 153 | const assertRedirect = (expectedPath: string, expectedParams: any) => { 154 | expect(router.push).toHaveBeenCalledWith({ 155 | pathname: expectedPath, 156 | params: expectedParams, 157 | }); 158 | }; 159 | -------------------------------------------------------------------------------- /hooks/useBalance.ts: -------------------------------------------------------------------------------- 1 | import { useAppStore } from "lib/state/appStore"; 2 | import useSWR from "swr"; 3 | import { createNwcFetcher } from "~/lib/createNwcFetcher"; 4 | 5 | const fetcher = createNwcFetcher(async (nwcClient) => { 6 | const balance = await nwcClient.getBalance(); 7 | return balance; 8 | }); 9 | 10 | export function useBalance() { 11 | const nwcClient = useAppStore((store) => store.nwcClient); 12 | const selectedWalletId = useAppStore((store) => store.selectedWalletId); 13 | return useSWR( 14 | nwcClient && `getBalance?selectedWalletId=${selectedWalletId}`, 15 | fetcher, 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /hooks/useGetFiatAmount.ts: -------------------------------------------------------------------------------- 1 | import { fiat } from "@getalby/lightning-tools"; 2 | import React from "react"; 3 | import { BTC_RATE_REFRESH_INTERVAL } from "~/lib/constants"; 4 | import { useAppStore } from "~/lib/state/appStore"; 5 | 6 | interface RateCacheEntry { 7 | rate: number; 8 | timestamp: number; 9 | } 10 | 11 | let cachedRates: Record = {}; 12 | 13 | function useFiatRate() { 14 | const fiatCurrency = useAppStore((store) => store.fiatCurrency) || "USD"; 15 | const [rate, setRate] = React.useState(); 16 | 17 | React.useEffect(() => { 18 | (async () => { 19 | try { 20 | const cacheEntry = cachedRates[fiatCurrency]; 21 | const now = Date.now(); 22 | if ( 23 | cacheEntry && 24 | now - cacheEntry.timestamp < BTC_RATE_REFRESH_INTERVAL 25 | ) { 26 | setRate(cacheEntry.rate); 27 | return; 28 | } 29 | setRate(undefined); 30 | const fetchedRate = await fiat.getFiatBtcRate(fiatCurrency); 31 | setRate(fetchedRate); 32 | cachedRates[fiatCurrency] = { rate: fetchedRate, timestamp: now }; 33 | } catch (error) { 34 | console.error(error); 35 | } 36 | })(); 37 | }, [fiatCurrency]); 38 | 39 | return rate; 40 | } 41 | 42 | export function useGetFiatAmount() { 43 | const fiatCurrency = useAppStore((store) => store.fiatCurrency) || "USD"; 44 | const rate = useFiatRate(); 45 | 46 | const getFiatAmount = React.useCallback( 47 | (amount: number, displayCurrencySign = true) => { 48 | if (rate) { 49 | if (displayCurrencySign) { 50 | return `${new Intl.NumberFormat(undefined, { 51 | style: "currency", 52 | currency: fiatCurrency, 53 | currencyDisplay: "narrowSymbol", 54 | }).format(rate * amount)}`; 55 | } 56 | 57 | const amountWithCurrencyCode = new Intl.NumberFormat("en-US", { 58 | style: "currency", 59 | currency: fiatCurrency, 60 | currencyDisplay: "code", 61 | }).format(rate * amount); 62 | 63 | return amountWithCurrencyCode.substring( 64 | amountWithCurrencyCode.search(/\s/) + 1, 65 | ); 66 | } 67 | return undefined; 68 | }, 69 | 70 | [rate, fiatCurrency], 71 | ); 72 | 73 | return rate ? getFiatAmount : undefined; 74 | } 75 | 76 | export function useGetSatsAmount() { 77 | const rate = useFiatRate(); 78 | 79 | const getSatsAmount = React.useCallback( 80 | (fiatAmount: number) => (rate ? Math.round(fiatAmount / rate) : undefined), 81 | [rate], 82 | ); 83 | 84 | return rate ? getSatsAmount : undefined; 85 | } 86 | -------------------------------------------------------------------------------- /hooks/useHandleLinking.ts: -------------------------------------------------------------------------------- 1 | import * as Linking from "expo-linking"; 2 | import { getInitialURL } from "expo-linking"; 3 | import { useEffect } from "react"; 4 | import { handleLink } from "~/lib/link"; 5 | import { useAppStore } from "~/lib/state/appStore"; 6 | import { useSession } from "./useSession"; 7 | 8 | export function useHandleLinking() { 9 | const { hasSession } = useSession(); 10 | const isOnboarded = useAppStore((store) => store.isOnboarded); 11 | const wallets = useAppStore((store) => store.wallets); 12 | 13 | useEffect(() => { 14 | // Do not process any deep links until the user is onboarded and authenticated 15 | // This prevents redirect loops between the deep link and /unlock, /onboarding 16 | if (!hasSession || !isOnboarded || !wallets.length) { 17 | return; 18 | } 19 | 20 | const processInitialURL = async () => { 21 | const url = await getInitialURL(); 22 | if (url) { 23 | await handleLink(url); 24 | } 25 | }; 26 | 27 | processInitialURL(); 28 | 29 | const subscription = Linking.addEventListener( 30 | "url", 31 | async (event: { url: string }) => { 32 | await handleLink(event.url); 33 | }, 34 | ); 35 | 36 | return () => subscription.remove(); 37 | }, [hasSession, isOnboarded, wallets.length]); 38 | } 39 | -------------------------------------------------------------------------------- /hooks/useInfo.ts: -------------------------------------------------------------------------------- 1 | import { useAppStore } from "lib/state/appStore"; 2 | import useSWR from "swr"; 3 | import { createNwcFetcher } from "~/lib/createNwcFetcher"; 4 | 5 | const fetcher = createNwcFetcher(async (nwcClient) => { 6 | const info = await nwcClient.getInfo(); 7 | return info; 8 | }); 9 | 10 | export function useInfo() { 11 | const nwcClient = useAppStore((store) => store.nwcClient); 12 | return useSWR(nwcClient && "getInfo", fetcher); 13 | } 14 | -------------------------------------------------------------------------------- /hooks/useSession.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, type PropsWithChildren } from "react"; 2 | import { useAppStore } from "~/lib/state/appStore"; 3 | 4 | const AuthContext = createContext<{ 5 | signIn: () => void; 6 | signOut: () => void; 7 | hasSession: boolean; 8 | }>({ 9 | signIn: () => null, 10 | signOut: () => null, 11 | hasSession: false, 12 | }); 13 | 14 | // This hook can be used to access the user info. 15 | export function useSession() { 16 | const value = useContext(AuthContext); 17 | if (process.env.NODE_ENV !== "production") { 18 | if (!value) { 19 | throw new Error("useSession must be wrapped in a "); 20 | } 21 | } 22 | 23 | return value; 24 | } 25 | 26 | export function SessionProvider({ children }: PropsWithChildren) { 27 | const appStore = useAppStore(); 28 | const isSecurityEnabled = useAppStore((store) => store.isSecurityEnabled); 29 | const unlocked = useAppStore((store) => store.unlocked); 30 | 31 | return ( 32 | { 35 | appStore.setUnlocked(true); 36 | }, 37 | signOut: () => { 38 | appStore.setUnlocked(false); 39 | }, 40 | hasSession: !isSecurityEnabled || (isSecurityEnabled && unlocked), 41 | }} 42 | > 43 | {children} 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /hooks/useTransactions.ts: -------------------------------------------------------------------------------- 1 | import { useAppStore } from "lib/state/appStore"; 2 | import useSWR from "swr"; 3 | import { TRANSACTIONS_PAGE_SIZE } from "~/lib/constants"; 4 | import { createNwcFetcher } from "~/lib/createNwcFetcher"; 5 | 6 | const fetcher = createNwcFetcher(async (nwcClient, args) => { 7 | const transactionsUrl = new URL("http://" + (args[0] as string)); 8 | const page = +(transactionsUrl.searchParams.get("page") as string); 9 | const transactions = await nwcClient.listTransactions({ 10 | limit: TRANSACTIONS_PAGE_SIZE, 11 | offset: (page - 1) * TRANSACTIONS_PAGE_SIZE, 12 | unpaid_outgoing: true, 13 | }); 14 | return transactions; 15 | }); 16 | 17 | export function useTransactions(page = 1) { 18 | const nwcClient = useAppStore((store) => store.nwcClient); 19 | const selectedWalletId = useAppStore((store) => store.selectedWalletId); 20 | return useSWR( 21 | nwcClient && 22 | `listTransactions?page=${page}&selectedWalletId=${selectedWalletId}`, 23 | fetcher, 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import "expo-router/entry"; 2 | import "lib/applyGlobalPolyfills"; 3 | -------------------------------------------------------------------------------- /lib/applyGlobalPolyfills.ts: -------------------------------------------------------------------------------- 1 | import "message-port-polyfill"; 2 | import "react-native-get-random-values"; 3 | import "react-native-url-polyfill/auto"; 4 | const TextEncodingPolyfill = require("text-encoding"); 5 | 6 | const applyGlobalPolyfills = () => { 7 | Object.assign(global, { 8 | TextEncoder: TextEncodingPolyfill.TextEncoder, 9 | TextDecoder: TextEncodingPolyfill.TextDecoder, 10 | }); 11 | }; 12 | 13 | applyGlobalPolyfills(); 14 | -------------------------------------------------------------------------------- /lib/bech32.ts: -------------------------------------------------------------------------------- 1 | import { bech32 } from "bech32"; 2 | import { Buffer } from "buffer"; 3 | 4 | // from https://github.com/getAlby/lightning-browser-extension/blob/master/src/common/utils/helpers.ts 5 | export function bech32Decode(str: string, encoding: BufferEncoding = "utf-8") { 6 | const { words: dataPart } = bech32.decode(str, 2000); 7 | const requestByteArray = bech32.fromWords(dataPart); 8 | return Buffer.from(requestByteArray).toString(encoding); 9 | } 10 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { Nip47Capability } from "@getalby/sdk/dist/nwc"; 2 | import Constants, { ExecutionEnvironment } from "expo-constants"; 3 | 4 | export const NAV_THEME = { 5 | light: { 6 | background: "hsl(0 0% 100%)", // background 7 | border: "hsl(240 5.9% 90%)", // border 8 | card: "hsl(0 0% 100%)", // card 9 | notification: "hsl(0 84.2% 60.2%)", // destructive 10 | primary: "hsl(240 5.9% 10%)", // primary 11 | text: "hsl(240 10% 3.9%)", // foreground 12 | }, 13 | dark: { 14 | background: "hsl(240 10% 3.9%)", // background 15 | border: "hsl(240 3.7% 15.9%)", // border 16 | card: "hsl(240 10% 3.9%)", // card 17 | notification: "hsl(0 72% 51%)", // destructive 18 | primary: "hsl(0 0% 98%)", // primary 19 | text: "hsl(0 0% 98%)", // foreground 20 | }, 21 | }; 22 | 23 | export const SUITE_NAME = "group.com.getalby.mobile.nse"; 24 | 25 | export const INACTIVITY_THRESHOLD = 5 * 60 * 1000; 26 | 27 | export const CURSOR_COLOR = "hsl(47 100% 72%)"; 28 | 29 | export const TRANSACTIONS_PAGE_SIZE = 20; 30 | 31 | export const DEFAULT_CURRENCY = "USD"; 32 | export const BTC_RATE_REFRESH_INTERVAL = 5 * 60 * 1000; 33 | export const DEFAULT_WALLET_NAME = "Default Wallet"; 34 | export const ALBY_LIGHTNING_ADDRESS = "go@getalby.com"; 35 | export const ALBY_URL = "https://getalby.com"; 36 | export const NOSTR_API_URL = "https://api.getalby.com/nwc"; 37 | 38 | export const REQUIRED_CAPABILITIES: Nip47Capability[] = [ 39 | "get_balance", 40 | "make_invoice", 41 | "pay_invoice", 42 | "list_transactions", 43 | ]; 44 | 45 | export const SATS_REGEX = /^\d*$/; 46 | 47 | export const FIAT_REGEX = /^\d*((\.|,)\d{0,2})?$/; 48 | 49 | export const BOLT11_REGEX = /.*?((lnbcrt|lntb|lnbc)([0-9]{1,}[a-z0-9]+){1})/; 50 | 51 | export const IS_EXPO_GO = 52 | Constants.executionEnvironment === ExecutionEnvironment.StoreClient; 53 | -------------------------------------------------------------------------------- /lib/createNwcFetcher.ts: -------------------------------------------------------------------------------- 1 | import { NWCClient } from "@getalby/sdk/dist/nwc"; 2 | import { errorToast } from "~/lib/errorToast"; 3 | import { useAppStore } from "~/lib/state/appStore"; 4 | 5 | type FetchArgs = Parameters; 6 | 7 | export function createNwcFetcher( 8 | fetcherFunc: (nwcClient: NWCClient, args: FetchArgs) => Promise, 9 | ) { 10 | return async (...args: FetchArgs) => { 11 | const nwcClient = useAppStore.getState().nwcClient; 12 | if (!nwcClient) { 13 | throw new Error("No NWC client"); 14 | } 15 | const lastAppStateChangeTime = 16 | useAppStore.getState().lastAppStateChangeTime; 17 | try { 18 | const result = await fetcherFunc(nwcClient, args); 19 | return result; 20 | } catch (error) { 21 | if ( 22 | lastAppStateChangeTime !== useAppStore.getState().lastAppStateChangeTime 23 | ) { 24 | // the user backgrounded the app, on iOS the websocket connection 25 | // is severed 26 | console.info( 27 | "app was backgrounded while doing NWC request, ignoring error", 28 | { error }, 29 | ); 30 | throw new Error("app was backgrounded"); 31 | } 32 | console.error("NWC request failed", { error }); 33 | errorToast(error); 34 | throw error; 35 | } 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /lib/errorToast.ts: -------------------------------------------------------------------------------- 1 | import Toast from "react-native-toast-message"; 2 | 3 | export function errorToast(error: Error | unknown) { 4 | Toast.show({ 5 | type: "error", 6 | text1: 7 | (error as Error | undefined)?.message || 8 | "An unknown error occured. Please check your internet connection or try restarting Alby Go", 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /lib/initiatePaymentFlow.ts: -------------------------------------------------------------------------------- 1 | import { Invoice } from "@getalby/lightning-tools"; 2 | import { router } from "expo-router"; 3 | import { lnurl as lnurlLib } from "lib/lnurl"; 4 | import { errorToast } from "~/lib/errorToast"; 5 | import { convertMerchantQRToLightningAddress } from "~/lib/merchants"; 6 | 7 | export async function initiatePaymentFlow( 8 | text: string, 9 | amount: string, 10 | ): Promise { 11 | // Some apps use uppercased LIGHTNING: prefixes 12 | text = text.toLowerCase(); 13 | console.info("loading payment", text); 14 | const originalText = text; 15 | 16 | try { 17 | if (text.startsWith("bitcoin:")) { 18 | const universalUrl = text.replace("bitcoin:", "http://"); 19 | const url = new URL(universalUrl); 20 | const lightningParam = url.searchParams.get("lightning"); 21 | if (!lightningParam) { 22 | throw new Error("No lightning param found in bitcoin payment link"); 23 | } 24 | text = lightningParam; 25 | } 26 | 27 | if (text.startsWith("lightning:")) { 28 | text = text.substring("lightning:".length); 29 | } 30 | 31 | // convert picknpay QRs to lighnting addresses 32 | const merchantLightningAddress = convertMerchantQRToLightningAddress(text); 33 | if (merchantLightningAddress) { 34 | text = merchantLightningAddress; 35 | } 36 | 37 | const lnurl = lnurlLib.findLnurl(text); 38 | console.info("Checked lnurl value", text, lnurl); 39 | 40 | if (lnurl) { 41 | const lnurlDetails = await lnurlLib.getDetails(lnurl); 42 | 43 | if ( 44 | lnurlDetails.tag !== "payRequest" && 45 | lnurlDetails.tag !== "withdrawRequest" 46 | ) { 47 | throw new Error("LNURL tag not supported"); 48 | } 49 | 50 | if (lnurlDetails.tag === "withdrawRequest") { 51 | router.replace({ 52 | pathname: "/withdraw", 53 | params: { url: lnurl }, 54 | }); 55 | return true; 56 | } 57 | 58 | if (lnurlDetails.tag === "payRequest") { 59 | router.replace({ 60 | pathname: "/send/lnurl-pay", 61 | params: { 62 | lnurlDetailsJSON: JSON.stringify(lnurlDetails), 63 | receiver: lnurl, 64 | amount, 65 | }, 66 | }); 67 | return true; 68 | } 69 | } else { 70 | // Check if this is a valid invoice 71 | const invoice = new Invoice({ pr: text }); 72 | 73 | if (invoice.satoshi === 0) { 74 | router.replace({ 75 | pathname: "/send/0-amount", 76 | params: { 77 | invoice: text, 78 | comment: invoice.description, 79 | }, 80 | }); 81 | return true; 82 | } 83 | 84 | router.replace({ 85 | pathname: "/send/confirm", 86 | params: { invoice: text }, 87 | }); 88 | return true; 89 | } 90 | } catch (error) { 91 | console.error("failed to load payment", originalText, error); 92 | errorToast(error); 93 | } 94 | 95 | return false; 96 | } 97 | -------------------------------------------------------------------------------- /lib/isBiometricSupported.ts: -------------------------------------------------------------------------------- 1 | import * as LocalAuthentication from "expo-local-authentication"; 2 | 3 | export async function isBiometricSupported() { 4 | const compatible = await LocalAuthentication.hasHardwareAsync(); 5 | const securityLevel = await LocalAuthentication.getEnrolledLevelAsync(); 6 | return compatible && securityLevel > 0; 7 | } 8 | -------------------------------------------------------------------------------- /lib/merchants.ts: -------------------------------------------------------------------------------- 1 | // from https://github.com/GaloyMoney/galoy-client/blob/main/src/parsing/merchants.ts 2 | 3 | type MerchantConfig = { 4 | id: string; 5 | identifierRegex: RegExp; 6 | defaultDomain: string; 7 | }; 8 | 9 | export const merchants: MerchantConfig[] = [ 10 | { 11 | id: "picknpay", 12 | identifierRegex: /(?.*za\.co\.electrum\.picknpay.*)/iu, 13 | defaultDomain: "cryptoqr.net", 14 | }, 15 | { 16 | id: "ecentric", 17 | identifierRegex: /(?.*za\.co\.ecentric.*)/iu, 18 | defaultDomain: "cryptoqr.net", 19 | }, 20 | ]; 21 | 22 | export const convertMerchantQRToLightningAddress = ( 23 | qrContent: string, 24 | ): string | null => { 25 | if (!qrContent) { 26 | return null; 27 | } 28 | 29 | for (const merchant of merchants) { 30 | const match = qrContent.match(merchant.identifierRegex); 31 | if (match?.groups?.identifier) { 32 | const domain = merchant.defaultDomain; 33 | return `${encodeURIComponent(match.groups.identifier)}@${domain}`; 34 | } 35 | } 36 | 37 | return null; 38 | }; 39 | -------------------------------------------------------------------------------- /lib/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native"; 2 | import { NOSTR_API_URL } from "~/lib/constants"; 3 | import { errorToast } from "~/lib/errorToast"; 4 | import { useAppStore, Wallet } from "~/lib/state/appStore"; 5 | import { 6 | computeSharedSecret, 7 | getConversationKey, 8 | getPubkeyFromNWCUrl, 9 | } from "~/lib/utils"; 10 | import { removeWalletInfo, storeWalletInfo } from "~/lib/walletInfo"; 11 | 12 | export async function registerWalletNotifications( 13 | wallet: Wallet, 14 | walletId: number, 15 | ) { 16 | try { 17 | if (!(wallet.nwcCapabilities || []).includes("notifications")) { 18 | throw new Error(`${wallet.name} does not have notifications capability`); 19 | } 20 | 21 | const nwcClient = useAppStore.getState().getNWCClient(walletId); 22 | if (!nwcClient) { 23 | return; 24 | } 25 | 26 | const walletServiceInfo = await nwcClient.getWalletServiceInfo(); 27 | const isNip44 = walletServiceInfo.encryptions.includes("nip44_v2"); 28 | 29 | const pushToken = useAppStore.getState().expoPushToken; 30 | if (!pushToken) { 31 | throw new Error("Push token is not set"); 32 | } 33 | 34 | const body = { 35 | pushToken, 36 | relayUrl: nwcClient.relayUrl, 37 | connectionPubkey: nwcClient.publicKey, 38 | walletPubkey: nwcClient.walletPubkey, 39 | isIOS: Platform.OS === "ios", 40 | // This is for http-nostr to know the encryption 41 | // TODO: replace with nip44 flag 42 | ...(isNip44 ? { version: "1.0" } : {}), 43 | }; 44 | 45 | const response = await fetch(`${NOSTR_API_URL}/nip47/notifications/push`, { 46 | method: "POST", 47 | headers: { 48 | Accept: "application/json", 49 | "Accept-encoding": "gzip, deflate", 50 | "Content-Type": "application/json", 51 | }, 52 | body: JSON.stringify(body), 53 | }); 54 | 55 | if (response.ok) { 56 | const responseData = await response.json(); 57 | useAppStore.getState().updateWallet( 58 | { 59 | pushId: responseData.subscriptionId, 60 | }, 61 | walletId, 62 | ); 63 | } else { 64 | new Error(`Error: ${response.status} ${response.statusText}`); 65 | } 66 | 67 | const walletData = { 68 | name: wallet.name ?? "", 69 | sharedSecret: isNip44 70 | ? getConversationKey(nwcClient.walletPubkey, nwcClient.secret ?? "") 71 | : computeSharedSecret(nwcClient.walletPubkey, nwcClient.secret ?? ""), 72 | id: walletId, 73 | // This is for Alby Go's notification service to know the encryption 74 | // "1.0" is nip44_v2, "0.0" is nip04 75 | version: isNip44 ? "1.0" : "0.0", 76 | }; 77 | 78 | try { 79 | await storeWalletInfo(nwcClient.publicKey, walletData); 80 | } catch (storageError) { 81 | console.error(storageError); 82 | errorToast(new Error("Failed to save wallet data")); 83 | } 84 | } catch (error) { 85 | errorToast(error); 86 | } 87 | } 88 | 89 | export async function deregisterWalletNotifications( 90 | wallet: Wallet, 91 | walletId: number, 92 | ) { 93 | if (!wallet.pushId) { 94 | return; 95 | } 96 | try { 97 | // TODO: wallets with the same secret if added will have the same token, 98 | // hence deregistering one might make others not work but will show 99 | // as ON because their push ids are not removed from the wallet store 100 | const response = await fetch( 101 | `${NOSTR_API_URL}/subscriptions/${wallet.pushId}`, 102 | { 103 | method: "DELETE", 104 | }, 105 | ); 106 | // FIXME: if deregistering fails, app will keep receiving notifications from the server 107 | if (!response.ok) { 108 | throw new Error("Failed to deregister push notifications"); 109 | } 110 | useAppStore.getState().updateWallet( 111 | { 112 | pushId: "", 113 | }, 114 | walletId, 115 | ); 116 | const pubkey = getPubkeyFromNWCUrl(wallet.nostrWalletConnectUrl ?? ""); 117 | if (pubkey) { 118 | await removeWalletInfo(pubkey, walletId); 119 | } 120 | } catch (error) { 121 | errorToast(error); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /lib/secureStorage.ts: -------------------------------------------------------------------------------- 1 | import * as SecureStore from "expo-secure-store"; 2 | 3 | export const secureStorage = { 4 | getItem: (key: string) => { 5 | return SecureStore.getItem(key); 6 | }, 7 | setItem: (key: string, value: string) => { 8 | return SecureStore.setItem(key, value); 9 | }, 10 | removeItem: (key: string) => { 11 | return SecureStore.deleteItemAsync(key); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /lib/swr.ts: -------------------------------------------------------------------------------- 1 | import { SWRConfiguration } from "swr"; 2 | // import AsyncStorage from "@react-native-async-storage/async-storage"; 3 | 4 | export const swrConfiguration: SWRConfiguration = { 5 | // TODO: add async storage for cache provider 6 | }; 7 | -------------------------------------------------------------------------------- /lib/useColorScheme.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme as useNativewindColorScheme } from "nativewind"; 2 | import { useAppStore } from "~/lib/state/appStore"; 3 | 4 | export function useColorScheme() { 5 | const { 6 | colorScheme, 7 | setColorScheme, 8 | toggleColorScheme: _toggleColorScheme, 9 | } = useNativewindColorScheme(); 10 | 11 | const isDarkColorScheme = colorScheme === "dark"; 12 | 13 | const toggleColorScheme = () => { 14 | _toggleColorScheme(); 15 | useAppStore.getState().setTheme(isDarkColorScheme ? "light" : "dark"); 16 | }; 17 | 18 | return { 19 | colorScheme: colorScheme ?? "dark", 20 | isDarkColorScheme, 21 | setColorScheme, 22 | toggleColorScheme, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { nwc } from "@getalby/sdk"; 2 | import { secp256k1 } from "@noble/curves/secp256k1"; 3 | import { extract as hkdf_extract } from "@noble/hashes/hkdf"; 4 | import { sha256 } from "@noble/hashes/sha256"; 5 | import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; 6 | import { Buffer } from "buffer"; 7 | import { clsx, type ClassValue } from "clsx"; 8 | import { getPublicKey, nip19 } from "nostr-tools"; 9 | import { twMerge } from "tailwind-merge"; 10 | 11 | export function cn(...inputs: ClassValue[]) { 12 | return twMerge(clsx(inputs)); 13 | } 14 | 15 | export function computeSharedSecret(pub: string, sk: string): string { 16 | const sharedSecret = secp256k1.getSharedSecret(sk, "02" + pub); 17 | const normalizedKey = sharedSecret.slice(1); 18 | return Buffer.from(normalizedKey).toString("hex"); 19 | } 20 | 21 | export function getConversationKey(pub: string, sk: string): string { 22 | const sharedX = secp256k1.getSharedSecret(sk, "02" + pub).subarray(1, 33); 23 | return bytesToHex(hkdf_extract(sha256, sharedX, "nip44-v2")); 24 | } 25 | 26 | export function getPubkeyFromNWCUrl(nwcUrl: string): string | undefined { 27 | const nwcOptions = nwc.NWCClient.parseWalletConnectUrl(nwcUrl); 28 | if (nwcOptions.secret) { 29 | return getPublicKey(hexToBytes(nwcOptions.secret)); 30 | } 31 | } 32 | 33 | export function safeNpubEncode(hex: string): string | undefined { 34 | try { 35 | return nip19.npubEncode(hex); 36 | } catch { 37 | return undefined; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/walletInfo.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native"; 2 | import { IS_EXPO_GO, SUITE_NAME } from "~/lib/constants"; 3 | 4 | let UserDefaults: any; 5 | let SharedPreferences: any; 6 | 7 | // this is done because accessing values stored from expo-secure-store 8 | // is quite difficult and we do not wish to complicate the notification 9 | // service extension (ios) or messaging service (android) 10 | if (!IS_EXPO_GO) { 11 | if (Platform.OS === "ios") { 12 | UserDefaults = 13 | require("@alevy97/react-native-userdefaults/src/ReactNativeUserDefaults.ios").default; 14 | } else { 15 | SharedPreferences = require("@getalby/expo-shared-preferences"); 16 | } 17 | } 18 | 19 | type WalletInfo = { 20 | name: string; 21 | sharedSecret: string; 22 | id: number; 23 | version: string; 24 | }; 25 | 26 | type Wallets = { 27 | [publicKey: string]: Partial; 28 | }; 29 | 30 | // TODO: In the future when we deprecate NIP-04 and stop 31 | // support for version 0.0 we would have display wallets 32 | // using 0.0 as deprecated and write a migration 33 | export async function storeWalletInfo( 34 | publicKey: string, 35 | walletData: Partial, 36 | ) { 37 | if (Platform.OS === "ios") { 38 | const groupDefaults = new UserDefaults(SUITE_NAME); 39 | const wallets = (await groupDefaults.get("wallets")) || {}; 40 | wallets[publicKey] = { 41 | ...(wallets[publicKey] || {}), 42 | ...walletData, 43 | }; 44 | await groupDefaults.set("wallets", wallets); 45 | } else { 46 | const walletsString = await SharedPreferences.getItemAsync("wallets"); 47 | const wallets: Wallets = walletsString ? JSON.parse(walletsString) : {}; 48 | wallets[publicKey] = { 49 | ...(wallets[publicKey] || {}), 50 | ...walletData, 51 | }; 52 | await SharedPreferences.setItemAsync("wallets", JSON.stringify(wallets)); 53 | } 54 | } 55 | 56 | export async function removeWalletInfo(publicKey: string, walletId: number) { 57 | if (Platform.OS === "ios") { 58 | const groupDefaults = new UserDefaults(SUITE_NAME); 59 | let wallets = await groupDefaults.get("wallets"); 60 | await groupDefaults.set("wallets", wallets); 61 | if (wallets) { 62 | wallets = removeWallet(wallets, publicKey, walletId); 63 | await groupDefaults.set("wallets", wallets); 64 | } 65 | } else { 66 | const walletsString = await SharedPreferences.getItemAsync("wallets"); 67 | let wallets: Wallets = walletsString ? JSON.parse(walletsString) : {}; 68 | if (wallets) { 69 | wallets = removeWallet(wallets, publicKey, walletId); 70 | await SharedPreferences.setItemAsync("wallets", JSON.stringify(wallets)); 71 | } 72 | } 73 | } 74 | 75 | export async function removeAllInfo() { 76 | if (Platform.OS === "ios") { 77 | const groupDefaults = new UserDefaults(SUITE_NAME); 78 | await groupDefaults.removeAll(); 79 | } else { 80 | await SharedPreferences.deleteItemAsync("wallets"); 81 | } 82 | } 83 | 84 | function removeWallet( 85 | wallets: Wallets, 86 | publicKey: string, 87 | walletId: number, 88 | ): Wallets { 89 | if (wallets && wallets[publicKey]) { 90 | delete wallets[publicKey]; 91 | for (const key in wallets) { 92 | const wallet = wallets[key]; 93 | if (wallet && wallet.id && wallet.id > walletId) { 94 | wallet.id -= 1; 95 | } 96 | } 97 | } 98 | return wallets; 99 | } 100 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.ts": () => "tsc --noEmit", 3 | "*.{js,jsx,ts,tsx}": ["prettier --write", "eslint --fix --max-warnings 0"], 4 | "*.{css,md,json}": ["prettier --write"], 5 | }; 6 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig } = require("expo/metro-config"); 2 | const { withNativeWind } = require("nativewind/metro"); 3 | 4 | // eslint-disable-next-line no-undef 5 | const config = getDefaultConfig(__dirname); 6 | 7 | module.exports = withNativeWind(config, { input: "./global.css" }); 8 | -------------------------------------------------------------------------------- /native/notifications/service.ts: -------------------------------------------------------------------------------- 1 | import Constants from "expo-constants"; 2 | import * as Device from "expo-device"; 3 | import * as ExpoNotifications from "expo-notifications"; 4 | import { Platform } from "react-native"; 5 | import { errorToast } from "~/lib/errorToast"; 6 | import { registerWalletNotifications } from "~/lib/notifications"; 7 | import { useAppStore } from "~/lib/state/appStore"; 8 | 9 | async function getPushTokenWithTimeout({ 10 | projectId, 11 | timeoutMs = 3000, 12 | }: { 13 | projectId: string; 14 | timeoutMs?: number; 15 | }): Promise { 16 | let timeoutId: ReturnType; 17 | 18 | const tokenPromise = ExpoNotifications.getExpoPushTokenAsync({ 19 | projectId, 20 | }).then((result) => { 21 | clearTimeout(timeoutId); 22 | return result.data; 23 | }); 24 | 25 | const timeoutPromise = new Promise((_resolve, reject) => { 26 | timeoutId = setTimeout( 27 | () => reject(new Error("FCM not available or not responding")), 28 | timeoutMs, 29 | ); 30 | }); 31 | 32 | return await Promise.race([tokenPromise, timeoutPromise]); 33 | } 34 | 35 | export async function registerForPushNotificationsAsync(): Promise< 36 | boolean | null 37 | > { 38 | if (!Device.isDevice) { 39 | errorToast("Must use physical device for push notifications"); 40 | return false; 41 | } 42 | 43 | if (Platform.OS === "android") { 44 | ExpoNotifications.setNotificationChannelAsync("default", { 45 | name: "default", 46 | importance: ExpoNotifications.AndroidImportance.MAX, 47 | vibrationPattern: [0, 250, 250, 250], 48 | enableVibrate: true, 49 | }); 50 | } 51 | 52 | const { status: existingStatus } = 53 | await ExpoNotifications.getPermissionsAsync(); 54 | let finalStatus = existingStatus; 55 | if (existingStatus !== "granted") { 56 | const { status } = await ExpoNotifications.requestPermissionsAsync(); 57 | finalStatus = status; 58 | } 59 | if (finalStatus === "undetermined") { 60 | return null; 61 | } 62 | if (finalStatus === "denied") { 63 | if (existingStatus === "denied") { 64 | errorToast(new Error("Enable app notifications in device settings")); 65 | } 66 | return false; 67 | } 68 | const projectId = 69 | Constants?.expoConfig?.extra?.eas?.projectId ?? 70 | Constants?.easConfig?.projectId; 71 | if (!projectId) { 72 | errorToast(new Error("Project ID not found")); 73 | } 74 | try { 75 | const pushToken = await getPushTokenWithTimeout({ 76 | projectId, 77 | }); 78 | 79 | useAppStore.getState().setExpoPushToken(pushToken); 80 | 81 | const wallets = useAppStore.getState().wallets; 82 | 83 | for (let i = 0; i < wallets.length; i++) { 84 | const wallet = wallets[i]; 85 | await registerWalletNotifications(wallet, i); 86 | } 87 | 88 | return ( 89 | wallets.length === 0 || 90 | useAppStore.getState().wallets.some((wallet) => wallet.pushId) 91 | ); 92 | } catch (error) { 93 | errorToast(error); 94 | } 95 | 96 | return false; 97 | } 98 | -------------------------------------------------------------------------------- /nativewind-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alby-go", 3 | "version": "1.13.1", 4 | "main": "./index.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "start:clean": "expo start --reset-cache", 8 | "start:tunnel": "expo start --tunnel", 9 | "android": "expo run:android", 10 | "ios": "expo run:ios", 11 | "device:ios": "npx expo run:ios --device", 12 | "device:android": "npx expo run:android --device", 13 | "eas:build:ios:preview": "eas build --profile preview --platform ios", 14 | "eas:build:android:preview": "eas build --profile preview --platform android", 15 | "eas:build:android": "eas build --platform android", 16 | "eas:build:ios": "eas build --platform ios", 17 | "eas:submit:ios": "eas submit -p ios --latest", 18 | "ts:check": "tsc", 19 | "prepare": "husky", 20 | "lint": "yarn lint:js && yarn tsc:compile && yarn format:fix --report-unused-disable-directives --max-warnings 0", 21 | "lint:js": "eslint . --max-warnings 0", 22 | "lint:js:fix": "eslint . --fix", 23 | "tsc:compile": "tsc --noEmit", 24 | "format": "prettier --check '**/*.{js,ts,tsx,md,json}'", 25 | "format:fix": "prettier --log-level silent --write '**/*.{js,ts,tsx,md,json}'", 26 | "test": "jest", 27 | "test:watch": "jest --watchAll" 28 | }, 29 | "dependencies": { 30 | "@alevy97/react-native-userdefaults": "^0.2.2", 31 | "@getalby/expo-shared-preferences": "^0.0.1", 32 | "@getalby/lightning-tools": "^5.1.2", 33 | "@getalby/sdk": "^5.1.0", 34 | "@gorhom/bottom-sheet": "^5.1.2", 35 | "@noble/curves": "^1.6.0", 36 | "@popicons/react-native": "^0.0.22", 37 | "@react-native-async-storage/async-storage": "1.23.1", 38 | "@rn-primitives/dialog": "^1.0.3", 39 | "@rn-primitives/portal": "^1.0.3", 40 | "@rn-primitives/switch": "^1.0.3", 41 | "bech32": "^2.0.0", 42 | "buffer": "^6.0.3", 43 | "class-variance-authority": "^0.7.0", 44 | "clsx": "^2.1.1", 45 | "dayjs": "^1.11.10", 46 | "expo": "~52.0.42", 47 | "expo-camera": "~16.0.18", 48 | "expo-clipboard": "~7.0.1", 49 | "expo-constants": "~17.0.8", 50 | "expo-device": "~7.0.3", 51 | "expo-font": "~13.0.4", 52 | "expo-linear-gradient": "~14.0.2", 53 | "expo-linking": "~7.0.5", 54 | "expo-local-authentication": "~15.0.2", 55 | "expo-location": "^18.0.10", 56 | "expo-notification-service-extension-plugin": "^1.0.1", 57 | "expo-notifications": "~0.29.14", 58 | "expo-router": "~4.0.19", 59 | "expo-secure-store": "~14.0.1", 60 | "expo-splash-screen": "~0.29.22", 61 | "expo-status-bar": "~2.0.1", 62 | "expo-system-ui": "~4.0.9", 63 | "lottie-react-native": "7.1.0", 64 | "message-port-polyfill": "^0.2.0", 65 | "nativewind": "^4.0.1", 66 | "react": "18.3.1", 67 | "react-dom": "18.3.1", 68 | "react-native": "0.76.8", 69 | "react-native-gesture-handler": "~2.20.2", 70 | "react-native-get-random-values": "^1.9.0", 71 | "react-native-qrcode-svg": "^6.3.1", 72 | "react-native-reanimated": "~3.16.1", 73 | "react-native-safe-area-context": "4.12.0", 74 | "react-native-screens": "~4.4.0", 75 | "react-native-svg": "15.8.0", 76 | "react-native-toast-message": "^2.2.0", 77 | "react-native-url-polyfill": "^2.0.0", 78 | "react-native-webview": "^13.12.5", 79 | "swr": "^2.2.5", 80 | "tailwind-merge": "^2.3.0", 81 | "text-encoding": "^0.7.0", 82 | "zustand": "^4.5.2" 83 | }, 84 | "devDependencies": { 85 | "@babel/core": "^7.26.0", 86 | "@babel/preset-typescript": "^7.24.7", 87 | "@expo/config-plugins": "~9.0.0", 88 | "@testing-library/react-hooks": "^8.0.1", 89 | "@testing-library/react-native": "^12.7.2", 90 | "@types/jest": "^29.5.13", 91 | "@types/react": "~18.3.12", 92 | "eslint": "^8.57.0", 93 | "eslint-config-expo": "~8.0.1", 94 | "eslint-config-prettier": "^9.1.0", 95 | "eslint-plugin-prettier": "^5.2.1", 96 | "husky": "^9.1.6", 97 | "jest": "^29.7.0", 98 | "jest-expo": "~52.0.6", 99 | "lint-staged": "^15.2.10", 100 | "prettier": "^3.3.3", 101 | "tailwindcss": "^3.4.3", 102 | "typescript": "~5.3.3" 103 | }, 104 | "private": true, 105 | "jest": { 106 | "preset": "jest-expo", 107 | "transformIgnorePatterns": [ 108 | "node_modules/(?!(?:.pnpm/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg))" 109 | ] 110 | }, 111 | "expo": { 112 | "doctor": { 113 | "reactNativeDirectoryCheck": { 114 | "listUnknownPackages": false 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /pages/Onboarding.tsx: -------------------------------------------------------------------------------- 1 | import { openURL } from "expo-linking"; 2 | import { Link, Stack, router } from "expo-router"; 3 | import React from "react"; 4 | import { Image, View } from "react-native"; 5 | import { Button } from "~/components/ui/button"; 6 | import { Text } from "~/components/ui/text"; 7 | import { useAppStore } from "~/lib/state/appStore"; 8 | 9 | export function Onboarding() { 10 | async function finish() { 11 | useAppStore.getState().setOnboarded(true); 12 | router.replace("/"); 13 | } 14 | 15 | return ( 16 | 17 | 23 | 24 | 29 | 30 | Hello there 👋 31 | 32 | 33 | 34 | Alby Go 35 | {" "} 36 | works best with Alby Hub and is the easiest way to use Bitcoin 37 | wherever you are. 38 | 39 | 40 | 41 | 44 | 45 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /pages/Unlock.tsx: -------------------------------------------------------------------------------- 1 | import * as LocalAuthentication from "expo-local-authentication"; 2 | import { router, Stack } from "expo-router"; 3 | import React, { useCallback, useEffect } from "react"; 4 | import { Image, View } from "react-native"; 5 | 6 | import { Button } from "~/components/ui/button"; 7 | import { Text } from "~/components/ui/text"; 8 | import { useSession } from "~/hooks/useSession"; 9 | 10 | export function Unlock() { 11 | const [isUnlocking, setIsUnlocking] = React.useState(false); 12 | const { signIn } = useSession(); 13 | 14 | const handleUnlock = useCallback(async () => { 15 | // The call `signIn()` below triggers a re-render of the component 16 | // and would prompt the user again (before the actual redirect 17 | // happens and the view is replaced) 18 | if (isUnlocking) { 19 | return; 20 | } 21 | 22 | try { 23 | setIsUnlocking(true); 24 | const biometricAuth = await LocalAuthentication.authenticateAsync({ 25 | promptMessage: "Unlock Alby Go", 26 | }); 27 | if (biometricAuth.success) { 28 | signIn(); 29 | router.replace("/"); 30 | } 31 | } catch (e) { 32 | console.error(e); 33 | } finally { 34 | setIsUnlocking(false); 35 | } 36 | }, [isUnlocking, signIn]); 37 | 38 | useEffect(() => { 39 | handleUnlock(); 40 | }, [handleUnlock]); 41 | 42 | return ( 43 | 44 | 50 | 51 | 56 | 57 | Unlock to continue 58 | 59 | 60 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /pages/Wildcard.tsx: -------------------------------------------------------------------------------- 1 | import { router, Stack, useFocusEffect } from "expo-router"; 2 | import { View } from "react-native"; 3 | import Loading from "~/components/Loading"; 4 | import { Text } from "~/components/ui/text"; 5 | 6 | export function Wildcard() { 7 | // Should a user ever land on this page, redirect them to home 8 | useFocusEffect(() => { 9 | router.replace({ 10 | pathname: "/", 11 | }); 12 | }); 13 | 14 | return ( 15 | 16 | null, // hide header completely 20 | }} 21 | /> 22 | 23 | Loading 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /pages/receive/AlbyAccount.tsx: -------------------------------------------------------------------------------- 1 | import { openURL } from "expo-linking"; 2 | import React from "react"; 3 | import { Dimensions, Image, ScrollView, View } from "react-native"; 4 | import { LinkIcon } from "~/components/Icons"; 5 | import Screen from "~/components/Screen"; 6 | import { Button } from "~/components/ui/button"; 7 | import { Text } from "~/components/ui/text"; 8 | 9 | export function AlbyAccount() { 10 | const dimensions = Dimensions.get("window"); 11 | const imageWidth = Math.round((dimensions.width * 3) / 5); 12 | 13 | return ( 14 | 15 | 16 | 17 | 22 | 23 | 24 | Get your lightning address with Alby Account 25 | 26 | 27 | 28 | {"\u2022 "}Lightning address & Nostr identifier, 29 | 30 | 31 | {"\u2022 "}Personal tipping page, 32 | 33 | 34 | {"\u2022 "}Access to podcasting 2.0 apps, 35 | 36 | 37 | {"\u2022 "}Buy bitcoin directly to your wallet, 38 | 39 | 40 | {"\u2022 "}Useful email wallet notifications, 41 | 42 | 43 | {"\u2022 "}Priority support. 44 | 45 | 46 | 47 | 48 | 49 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /pages/receive/Invoice.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CreateInvoice } from "~/components/CreateInvoice"; 3 | import Screen from "~/components/Screen"; 4 | 5 | export function Invoice() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /pages/receive/LightningAddress.tsx: -------------------------------------------------------------------------------- 1 | import { router } from "expo-router"; 2 | import React from "react"; 3 | import { View } from "react-native"; 4 | import { QRIcon } from "~/components/Icons"; 5 | import Screen from "~/components/Screen"; 6 | import { Button } from "~/components/ui/button"; 7 | import { Text } from "~/components/ui/text"; 8 | import { useAppStore } from "~/lib/state/appStore"; 9 | 10 | export function LightningAddress() { 11 | const walletId = useAppStore((store) => store.selectedWalletId); 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | satoshi 20 | 21 | @getalby.com 22 | 23 | 24 | 25 | Attach your lightning address to this wallet to display it as QR code 26 | for fast face-to-face transactions 27 | 28 | 29 | 32 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /pages/receive/Receive.tsx: -------------------------------------------------------------------------------- 1 | import * as Clipboard from "expo-clipboard"; 2 | import { router } from "expo-router"; 3 | import React from "react"; 4 | import { Share, TouchableOpacity, View } from "react-native"; 5 | import Toast from "react-native-toast-message"; 6 | import { CreateInvoice } from "~/components/CreateInvoice"; 7 | import { AddressIcon, EditIcon, ScanIcon, ShareIcon } from "~/components/Icons"; 8 | import RedeemIcon from "~/components/icons/RedeemIcon"; 9 | import QRCode from "~/components/QRCode"; 10 | import Screen from "~/components/Screen"; 11 | import { Button } from "~/components/ui/button"; 12 | import { Text } from "~/components/ui/text"; 13 | import { errorToast } from "~/lib/errorToast"; 14 | import { useAppStore } from "~/lib/state/appStore"; 15 | 16 | export function Receive() { 17 | const selectedWalletId = useAppStore((store) => store.selectedWalletId); 18 | const wallets = useAppStore((store) => store.wallets); 19 | const lightningAddress = wallets[selectedWalletId].lightningAddress; 20 | 21 | function copy() { 22 | const text = lightningAddress; 23 | if (!text) { 24 | errorToast(new Error("Nothing to copy")); 25 | return; 26 | } 27 | Clipboard.setStringAsync(text); 28 | Toast.show({ 29 | type: "success", 30 | text1: "Copied to clipboard", 31 | }); 32 | } 33 | 34 | async function share() { 35 | const message = lightningAddress; 36 | try { 37 | if (!message) { 38 | throw new Error("no lightning address set"); 39 | } 40 | await Share.share({ 41 | message, 42 | }); 43 | } catch (error) { 44 | console.error("Error sharing:", error); 45 | errorToast(error); 46 | } 47 | } 48 | 49 | return ( 50 | <> 51 | 55 | !lightningAddress && ( 56 | <> 57 | { 60 | router.push("/receive/lightning-address"); 61 | }} 62 | > 63 | 68 | 69 | { 72 | router.push("receive/withdraw"); 73 | }} 74 | > 75 | 80 | 81 | 82 | ) 83 | } 84 | /> 85 | {!lightningAddress ? ( 86 | 87 | ) : ( 88 | <> 89 | 90 | 91 | 92 | 93 | 94 | {lightningAddress} 95 | 96 | 97 | 98 | 99 | 100 | 101 | 111 | 119 | 129 | 130 | 131 | )} 132 | 133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /pages/receive/ReceiveSuccess.tsx: -------------------------------------------------------------------------------- 1 | import { Invoice } from "@getalby/lightning-tools"; 2 | import { router, useLocalSearchParams } from "expo-router"; 3 | import { ScrollView, View } from "react-native"; 4 | import { Tick } from "~/animations/Tick"; 5 | import AlbyGoLogo from "~/components/AlbyGoLogo"; 6 | import Screen from "~/components/Screen"; 7 | import { Button } from "~/components/ui/button"; 8 | import { Text } from "~/components/ui/text"; 9 | import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; 10 | 11 | export function ReceiveSuccess() { 12 | const { invoice } = useLocalSearchParams() as { invoice: string }; 13 | 14 | const getFiatAmount = useGetFiatAmount(); 15 | const decodedInvoice = new Invoice({ 16 | pr: invoice, 17 | }); 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Received 28 | 29 | 30 | 31 | 32 | + {new Intl.NumberFormat().format(+decodedInvoice.satoshi)}{" "} 33 | 34 | 35 | sats 36 | 37 | 38 | {getFiatAmount && ( 39 | 40 | {getFiatAmount(+decodedInvoice.satoshi)} 41 | 42 | )} 43 | 44 | {decodedInvoice.description && ( 45 | 46 | 47 | Description 48 | 49 | 50 | {decodedInvoice.description} 51 | 52 | 53 | )} 54 | 55 | 56 | 65 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /pages/send/AddressBook.tsx: -------------------------------------------------------------------------------- 1 | import { Link, router } from "expo-router"; 2 | import { Pressable, ScrollView, TouchableOpacity, View } from "react-native"; 3 | import { TrashIcon } from "~/components/Icons"; 4 | import Screen from "~/components/Screen"; 5 | import { Button } from "~/components/ui/button"; 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardTitle, 11 | } from "~/components/ui/card"; 12 | import { Text } from "~/components/ui/text"; 13 | import { useAppStore } from "~/lib/state/appStore"; 14 | 15 | export function AddressBook() { 16 | const addressBookEntries = useAppStore((store) => store.addressBookEntries); 17 | 18 | return ( 19 | 20 | 21 | 22 | {addressBookEntries.length > 0 ? ( 23 | addressBookEntries.map((addressBookEntry, index) => ( 24 | { 27 | router.dismissAll(); 28 | router.navigate({ 29 | pathname: "/send", 30 | params: { 31 | url: addressBookEntry.lightningAddress, 32 | }, 33 | }); 34 | }} 35 | className="mb-4" 36 | > 37 | 38 | 39 | 40 | 41 | {addressBookEntry.name?.[0]?.toUpperCase() || 42 | addressBookEntry.lightningAddress?.[0]?.toUpperCase() || 43 | "SN"} 44 | 45 | 46 | 47 | 48 | {addressBookEntry.name || 49 | addressBookEntry.lightningAddress} 50 | 51 | 52 | {addressBookEntry.lightningAddress} 53 | 54 | 55 | { 57 | e.stopPropagation(); 58 | useAppStore.getState().removeAddressBookEntry(index); 59 | }} 60 | > 61 | 62 | 63 | 64 | 65 | 66 | )) 67 | ) : ( 68 | No entries yet. 69 | )} 70 | 71 | 72 | 73 | 76 | 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /pages/send/Manual.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { View } from "react-native"; 3 | import DismissableKeyboardView from "~/components/DismissableKeyboardView"; 4 | import Screen from "~/components/Screen"; 5 | import { Button } from "~/components/ui/button"; 6 | import { Input } from "~/components/ui/input"; 7 | import { Text } from "~/components/ui/text"; 8 | import { errorToast } from "~/lib/errorToast"; 9 | import { initiatePaymentFlow } from "~/lib/initiatePaymentFlow"; 10 | 11 | export function Manual() { 12 | const [keyboardText, setKeyboardText] = useState(""); 13 | 14 | const submitKeyboardText = async () => { 15 | try { 16 | await initiatePaymentFlow(keyboardText, ""); 17 | } catch (error) { 18 | console.error("Payment failed:", error); 19 | errorToast(error); 20 | } 21 | }; 22 | 23 | return ( 24 | <> 25 | 26 | 27 | 28 | 29 | 30 | Type or paste a Lightning Address, lightning invoice or LNURL. 31 | 32 | 41 | 42 | 49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /pages/send/PaymentSuccess.tsx: -------------------------------------------------------------------------------- 1 | import { openURL } from "expo-linking"; 2 | import { router, useLocalSearchParams } from "expo-router"; 3 | import { LNURLPaymentSuccessAction } from "lib/lnurl"; 4 | import React from "react"; 5 | import { ScrollView, View } from "react-native"; 6 | import { Tick } from "~/animations/Tick"; 7 | import AlbyGoLogo from "~/components/AlbyGoLogo"; 8 | import { Receiver } from "~/components/Receiver"; 9 | import Screen from "~/components/Screen"; 10 | import { Button } from "~/components/ui/button"; 11 | import { Text } from "~/components/ui/text"; 12 | import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; 13 | 14 | export function PaymentSuccess() { 15 | const getFiatAmount = useGetFiatAmount(); 16 | const { receiver, amount, successAction } = useLocalSearchParams() as { 17 | preimage: string; 18 | receiver: string; 19 | amount: string; 20 | successAction: string; 21 | }; 22 | 23 | const lnurlSuccessAction: LNURLPaymentSuccessAction = successAction 24 | ? JSON.parse(successAction) 25 | : undefined; 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {new Intl.NumberFormat().format(Math.ceil(+amount))}{" "} 39 | 40 | 41 | sats 42 | 43 | 44 | {getFiatAmount && ( 45 | 46 | {getFiatAmount(+amount)} 47 | 48 | )} 49 | 50 | 51 | {lnurlSuccessAction && ( 52 | 53 | 54 | Message From Receiver 55 | 56 | {lnurlSuccessAction.tag === "message" && ( 57 | 58 | {lnurlSuccessAction.message} 59 | 60 | )} 61 | {lnurlSuccessAction.tag === "url" && ( 62 | <> 63 | {lnurlSuccessAction.description && ( 64 | 65 | {lnurlSuccessAction.description} 66 | 67 | )} 68 | {lnurlSuccessAction.url && ( 69 | 75 | )} 76 | 77 | )} 78 | 79 | )} 80 | 81 | 82 | 91 | 92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /pages/send/Send.tsx: -------------------------------------------------------------------------------- 1 | import * as Clipboard from "expo-clipboard"; 2 | import { router, useLocalSearchParams } from "expo-router"; 3 | import React from "react"; 4 | import { View } from "react-native"; 5 | import { BookUserIcon, KeyboardIcon, PasteIcon } from "~/components/Icons"; 6 | import Loading from "~/components/Loading"; 7 | import QRCodeScanner from "~/components/QRCodeScanner"; 8 | import Screen from "~/components/Screen"; 9 | import { Button } from "~/components/ui/button"; 10 | import { Text } from "~/components/ui/text"; 11 | import { errorToast } from "~/lib/errorToast"; 12 | import { initiatePaymentFlow } from "~/lib/initiatePaymentFlow"; 13 | import { handleLink } from "~/lib/link"; 14 | 15 | export function Send() { 16 | const { url, amount } = useLocalSearchParams<{ 17 | url: string; 18 | amount: string; 19 | }>(); 20 | 21 | const [isLoading, setLoading] = React.useState(false); 22 | const [startScanning, setStartScanning] = React.useState(false); 23 | 24 | async function paste() { 25 | let clipboardText; 26 | try { 27 | clipboardText = await Clipboard.getStringAsync(); 28 | } catch (error) { 29 | console.error("Failed to read clipboard", error); 30 | return; 31 | } 32 | await loadPayment(clipboardText); 33 | } 34 | 35 | const handleScanned = async (data: string) => { 36 | return loadPayment(data); 37 | }; 38 | 39 | const loadPayment = async (text: string, amount = "") => { 40 | if (!text) { 41 | errorToast(new Error("Your clipboard is empty.")); 42 | return false; 43 | } 44 | 45 | if (text.startsWith("nostr+walletauth") /* can have : or +alby: */) { 46 | handleLink(text); 47 | return true; 48 | } 49 | setLoading(true); 50 | const result = await initiatePaymentFlow(text, amount); 51 | setLoading(false); 52 | return result; 53 | }; 54 | 55 | React.useEffect(() => { 56 | if (url) { 57 | (async () => { 58 | const result = await loadPayment(url, amount); 59 | // Delay the camera to show the error message 60 | if (!result) { 61 | setTimeout(() => { 62 | setStartScanning(true); 63 | }, 2000); 64 | } 65 | })(); 66 | } else { 67 | setStartScanning(true); 68 | } 69 | }, [url, amount]); 70 | 71 | return ( 72 | <> 73 | 74 | {isLoading && ( 75 | 76 | 77 | 78 | )} 79 | {!isLoading && ( 80 | <> 81 | 85 | 86 | 96 | 106 | 114 | 115 | 116 | )} 117 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /pages/send/ZeroAmount.tsx: -------------------------------------------------------------------------------- 1 | import { router, useLocalSearchParams } from "expo-router"; 2 | import React from "react"; 3 | import { View } from "react-native"; 4 | import DismissableKeyboardView from "~/components/DismissableKeyboardView"; 5 | import { DualCurrencyInput } from "~/components/DualCurrencyInput"; 6 | import Loading from "~/components/Loading"; 7 | import Screen from "~/components/Screen"; 8 | import { Button } from "~/components/ui/button"; 9 | import { Text } from "~/components/ui/text"; 10 | import { errorToast } from "~/lib/errorToast"; 11 | 12 | export function ZeroAmount() { 13 | const { invoice, comment } = useLocalSearchParams() as { 14 | invoice: string; 15 | comment: string; 16 | }; 17 | const [isLoading, setLoading] = React.useState(false); 18 | const [amount, setAmount] = React.useState(""); 19 | 20 | async function submit() { 21 | setLoading(true); 22 | try { 23 | router.push({ 24 | pathname: "/send/confirm", 25 | params: { 26 | invoice, 27 | amount, 28 | }, 29 | }); 30 | } catch (error) { 31 | console.error(error); 32 | errorToast(error); 33 | } 34 | setLoading(false); 35 | } 36 | 37 | return ( 38 | <> 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | {comment && ( 53 | 54 | 55 | Comment 56 | 57 | 58 | {comment} 59 | 60 | 61 | )} 62 | 63 | 64 | 73 | 74 | 75 | 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /pages/settings/BitcoinMap.tsx: -------------------------------------------------------------------------------- 1 | import * as Location from "expo-location"; 2 | import React from "react"; 3 | import { TouchableOpacity, View } from "react-native"; 4 | import { WebView } from "react-native-webview"; 5 | import BTCMapModal from "~/components/BTCMapModal"; 6 | import { HelpCircleIcon } from "~/components/Icons"; 7 | import Loading from "~/components/Loading"; 8 | import Screen from "~/components/Screen"; 9 | 10 | import { Text } from "~/components/ui/text"; 11 | import { errorToast } from "~/lib/errorToast"; 12 | 13 | export function BitcoinMap() { 14 | const [showModal, setShowModal] = React.useState(false); 15 | const [isLoading, setIsLoading] = React.useState(true); 16 | const [mapUrl, setMapUrl] = React.useState("https://btcmap.org/map"); 17 | 18 | React.useEffect(() => { 19 | (async () => { 20 | try { 21 | const { status } = await Location.requestForegroundPermissionsAsync(); 22 | if (status !== Location.PermissionStatus.GRANTED) { 23 | throw new Error("Permission to access location was denied."); 24 | } 25 | 26 | const location = await Location.getCurrentPositionAsync({}); 27 | setMapUrl( 28 | `https://btcmap.org/map#18/${location.coords.latitude}/${location.coords.longitude}`, 29 | ); 30 | } catch (error) { 31 | errorToast(error); 32 | } finally { 33 | setIsLoading(false); 34 | } 35 | })(); 36 | }, []); 37 | 38 | return ( 39 | 40 | ( 43 | <> 44 | setShowModal(true)} 46 | className="-mr-4 px-6" 47 | > 48 | 53 | 54 | setShowModal(false)} 57 | /> 58 | 59 | )} 60 | /> 61 | 62 | {isLoading ? ( 63 | 64 | 65 | Loading BTC Map 66 | 67 | ) : ( 68 | 73 | )} 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /pages/settings/FiatCurrency.tsx: -------------------------------------------------------------------------------- 1 | import { router } from "expo-router"; 2 | import React, { useEffect, useState } from "react"; 3 | import { FlatList, TouchableOpacity, View } from "react-native"; 4 | import Toast from "react-native-toast-message"; 5 | import Loading from "~/components/Loading"; 6 | import Screen from "~/components/Screen"; 7 | import { Input } from "~/components/ui/input"; 8 | import { Text } from "~/components/ui/text"; 9 | import { ALBY_URL } from "~/lib/constants"; 10 | import { errorToast } from "~/lib/errorToast"; 11 | import { useAppStore } from "~/lib/state/appStore"; 12 | import { cn } from "~/lib/utils"; 13 | 14 | export function FiatCurrency() { 15 | const [fiatCurrency, setFiatCurrency] = React.useState( 16 | useAppStore.getState().fiatCurrency, 17 | ); 18 | const [currencies, setCurrencies] = useState<[string, string][]>([]); 19 | const [filteredCurrencies, setFilteredCurrencies] = useState< 20 | [string, string][] 21 | >([]); 22 | const [loading, setLoading] = useState(true); 23 | const [searchQuery, setSearchQuery] = useState(""); 24 | 25 | useEffect(() => { 26 | async function fetchCurrencies() { 27 | try { 28 | const response = await fetch(`${ALBY_URL}/api/rates`); 29 | const data = await response.json(); 30 | 31 | const mappedCurrencies: [string, string][] = Object.entries(data).map( 32 | ([code, details]: any) => [code.toUpperCase(), details.name], 33 | ); 34 | 35 | mappedCurrencies.sort((a, b) => a[1].localeCompare(b[1])); 36 | 37 | setCurrencies(mappedCurrencies); 38 | } catch (error) { 39 | errorToast(error); 40 | } finally { 41 | setLoading(false); 42 | } 43 | } 44 | 45 | fetchCurrencies(); 46 | }, []); 47 | 48 | useEffect(() => { 49 | const filtered = currencies.filter( 50 | ([code, name]) => 51 | name.toLowerCase().includes(searchQuery.toLowerCase()) || 52 | code.toLowerCase().includes(searchQuery.toLowerCase()), 53 | ); 54 | setFilteredCurrencies(filtered); 55 | }, [searchQuery, currencies]); 56 | 57 | function select(iso: string) { 58 | setFiatCurrency(iso); 59 | useAppStore.getState().setFiatCurrency(iso); 60 | Toast.show({ 61 | type: "success", 62 | text1: "Fiat currency updated", 63 | }); 64 | router.back(); 65 | } 66 | 67 | return ( 68 | 69 | 70 | {loading ? ( 71 | 72 | ) : ( 73 | <> 74 | 79 | ( 82 | select(item[0])} 88 | > 89 | 95 | {item[1]} 96 | 97 | 98 | ({item[0]}) 99 | 100 | 101 | )} 102 | keyExtractor={(item) => item[0]} 103 | className="flex-1" 104 | /> 105 | 106 | )} 107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /pages/settings/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FlatList, View } from "react-native"; 3 | import Loading from "~/components/Loading"; 4 | import Screen from "~/components/Screen"; 5 | import { Label } from "~/components/ui/label"; 6 | import { Switch } from "~/components/ui/switch"; 7 | import { Text } from "~/components/ui/text"; 8 | import { errorToast } from "~/lib/errorToast"; 9 | import { 10 | deregisterWalletNotifications, 11 | registerWalletNotifications, 12 | } from "~/lib/notifications"; 13 | import { useAppStore, Wallet } from "~/lib/state/appStore"; 14 | import { cn } from "~/lib/utils"; 15 | import { registerForPushNotificationsAsync } from "~/services/Notifications"; 16 | 17 | export function Notifications() { 18 | const [isLoading, setLoading] = React.useState(false); 19 | const wallets = useAppStore((store) => store.wallets); 20 | const isEnabled = useAppStore((store) => store.isNotificationsEnabled); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 30 | {isLoading ? ( 31 | 32 | ) : ( 33 | { 36 | setLoading(true); 37 | let enabled: boolean | null = checked; 38 | if (enabled) { 39 | enabled = await registerForPushNotificationsAsync(); 40 | } else { 41 | const wallets = useAppStore.getState().wallets; 42 | for (const [id, wallet] of wallets.entries()) { 43 | await deregisterWalletNotifications(wallet, id); 44 | } 45 | enabled = useAppStore 46 | .getState() 47 | .wallets.some((wallet) => wallet.pushId); 48 | if (enabled) { 49 | errorToast("Failed to deregister notifications"); 50 | } 51 | } 52 | useAppStore.getState().setNotificationsEnabled(enabled); 53 | setLoading(false); 54 | }} 55 | nativeID="security" 56 | /> 57 | )} 58 | 59 | {wallets.length > 1 && ( 60 | <> 61 | 62 | 63 | Choose from which wallets you want to receive app notifications 64 | 65 | 66 | ( 73 | 78 | )} 79 | /> 80 | 81 | )} 82 | 83 | 84 | ); 85 | } 86 | 87 | function WalletNotificationSwitch({ 88 | wallet, 89 | index, 90 | isEnabled, 91 | }: { 92 | wallet: Wallet; 93 | index: number; 94 | isEnabled: boolean; 95 | }) { 96 | const [isLoading, setLoading] = React.useState(false); 97 | 98 | const handleSwitchToggle = async (checked: boolean) => { 99 | setLoading(true); 100 | if (checked) { 101 | await registerWalletNotifications(wallet, index); 102 | } else { 103 | await deregisterWalletNotifications(wallet, index); 104 | const hasNotificationsEnabled = useAppStore 105 | .getState() 106 | .wallets.some((wallet) => wallet.pushId); 107 | useAppStore.getState().setNotificationsEnabled(hasNotificationsEnabled); 108 | } 109 | setLoading(false); 110 | }; 111 | 112 | return ( 113 | 114 | 117 | {isLoading ? ( 118 | 119 | ) : ( 120 | 125 | )} 126 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /pages/settings/Security.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View } from "react-native"; 3 | import Alert from "~/components/Alert"; 4 | import { TriangleAlertIcon } from "~/components/Icons"; 5 | import Loading from "~/components/Loading"; 6 | import Screen from "~/components/Screen"; 7 | import { Label } from "~/components/ui/label"; 8 | import { Switch } from "~/components/ui/switch"; 9 | import { Text } from "~/components/ui/text"; 10 | import { isBiometricSupported } from "~/lib/isBiometricSupported"; 11 | import { useAppStore } from "~/lib/state/appStore"; 12 | import { cn } from "~/lib/utils"; 13 | 14 | export function Security() { 15 | const [isSupported, setIsSupported] = React.useState(null); 16 | const isEnabled = useAppStore((store) => store.isSecurityEnabled); 17 | 18 | React.useEffect(() => { 19 | async function checkBiometricSupport() { 20 | const supported = await isBiometricSupported(); 21 | setIsSupported(supported); 22 | } 23 | checkBiometricSupport(); 24 | }, []); 25 | 26 | return ( 27 | 28 | 29 | {isSupported === null ? ( 30 | 31 | 32 | 33 | ) : ( 34 | <> 35 | {!isSupported && ( 36 | 43 | )} 44 | 45 | 46 | 58 | { 62 | useAppStore.getState().setSecurityEnabled(!isEnabled); 63 | }} 64 | nativeID="security" 65 | /> 66 | 67 | 68 | 69 | )} 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /pages/settings/Wallets.tsx: -------------------------------------------------------------------------------- 1 | import { Link, router } from "expo-router"; 2 | import React from "react"; 3 | import { FlatList, TouchableOpacity, View } from "react-native"; 4 | import { SettingsIcon, WalletIcon } from "~/components/Icons"; 5 | import { Button } from "~/components/ui/button"; 6 | 7 | import Toast from "react-native-toast-message"; 8 | import Screen from "~/components/Screen"; 9 | import { Text } from "~/components/ui/text"; 10 | import { DEFAULT_WALLET_NAME } from "~/lib/constants"; 11 | import { useAppStore } from "~/lib/state/appStore"; 12 | import { cn } from "~/lib/utils"; 13 | 14 | export function Wallets() { 15 | const selectedWalletId = useAppStore((store) => store.selectedWalletId); 16 | const wallets = useAppStore((store) => store.wallets); 17 | return ( 18 | <> 19 | 20 | 21 | { 27 | const active = item.index === selectedWalletId; 28 | 29 | return ( 30 | { 32 | if (item.index !== selectedWalletId) { 33 | useAppStore.getState().setSelectedWalletId(item.index); 34 | router.dismissAll(); 35 | router.navigate("/"); 36 | Toast.show({ 37 | type: "success", 38 | text1: `Switched wallet to ${item.item.name || DEFAULT_WALLET_NAME}`, 39 | position: "top", 40 | }); 41 | } 42 | }} 43 | className={cn( 44 | "flex flex-row items-center justify-between p-6 rounded-2xl border-2", 45 | active ? "border-primary" : "border-transparent", 46 | )} 47 | > 48 | 49 | 50 | 55 | {item.item.name || DEFAULT_WALLET_NAME} 56 | 57 | 58 | 63 | 64 | 65 | 66 | 67 | 68 | ); 69 | }} 70 | ListFooterComponent={ 71 | 72 | 81 | 82 | } 83 | /> 84 | 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /pages/settings/address-book/NewAddressBookEntry.tsx: -------------------------------------------------------------------------------- 1 | import { router } from "expo-router"; 2 | import React from "react"; 3 | import { View } from "react-native"; 4 | import Toast from "react-native-toast-message"; 5 | import DismissableKeyboardView from "~/components/DismissableKeyboardView"; 6 | import Screen from "~/components/Screen"; 7 | import { Button } from "~/components/ui/button"; 8 | import { Input } from "~/components/ui/input"; 9 | import { Label } from "~/components/ui/label"; 10 | import { Text } from "~/components/ui/text"; 11 | import { useAppStore } from "~/lib/state/appStore"; 12 | 13 | export function NewAddressBookEntry() { 14 | const [name, setName] = React.useState(""); 15 | const [lightningAddress, setLightningAddress] = React.useState(""); 16 | return ( 17 | 18 | 19 | 20 | 21 | 24 | 35 | 41 | 51 | 52 | 53 | 69 | 70 | 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /pages/settings/wallets/LightningAddress.tsx: -------------------------------------------------------------------------------- 1 | import { router, useLocalSearchParams } from "expo-router"; 2 | import React from "react"; 3 | import { View } from "react-native"; 4 | import Toast from "react-native-toast-message"; 5 | import Alert from "~/components/Alert"; 6 | import DismissableKeyboardView from "~/components/DismissableKeyboardView"; 7 | import { AlertCircleIcon } from "~/components/Icons"; 8 | import Loading from "~/components/Loading"; 9 | import Screen from "~/components/Screen"; 10 | import { Button } from "~/components/ui/button"; 11 | import { Input } from "~/components/ui/input"; 12 | import { Text } from "~/components/ui/text"; 13 | import { useAppStore } from "~/lib/state/appStore"; 14 | 15 | export function SetLightningAddress() { 16 | const { id } = useLocalSearchParams() as { id: string }; 17 | const walletId = parseInt(id); 18 | const wallets = useAppStore((store) => store.wallets); 19 | const [lightningAddress, setLightningAddress] = React.useState(""); 20 | React.useEffect(() => { 21 | setLightningAddress(wallets[walletId].lightningAddress || ""); 22 | }, [wallets, walletId]); 23 | const [isLoading, setLoading] = React.useState(false); 24 | 25 | const updateLightningAddress = async () => { 26 | setLoading(true); 27 | useAppStore.getState().updateWallet({ lightningAddress }, walletId); 28 | Toast.show({ 29 | type: "success", 30 | text1: "Lightning address updated", 31 | }); 32 | router.back(); 33 | setLoading(false); 34 | }; 35 | 36 | return ( 37 | 38 | 39 | 40 | 47 | 48 | 49 | 50 | 51 | 52 | Lightning Address 53 | 54 | 65 | 66 | 67 | 68 | 77 | 78 | 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /pages/settings/wallets/RenameWallet.tsx: -------------------------------------------------------------------------------- 1 | import { router, useLocalSearchParams } from "expo-router"; 2 | import React from "react"; 3 | import { View } from "react-native"; 4 | import Toast from "react-native-toast-message"; 5 | import DismissableKeyboardView from "~/components/DismissableKeyboardView"; 6 | import Screen from "~/components/Screen"; 7 | import { Button } from "~/components/ui/button"; 8 | import { Input } from "~/components/ui/input"; 9 | import { Text } from "~/components/ui/text"; 10 | import { DEFAULT_WALLET_NAME, IS_EXPO_GO } from "~/lib/constants"; 11 | import { useAppStore } from "~/lib/state/appStore"; 12 | import { getPubkeyFromNWCUrl } from "~/lib/utils"; 13 | import { storeWalletInfo } from "~/lib/walletInfo"; 14 | 15 | export function RenameWallet() { 16 | const { id } = useLocalSearchParams() as { id: string }; 17 | const walletId = parseInt(id); 18 | const wallets = useAppStore((store) => store.wallets); 19 | const isNotificationsEnabled = useAppStore( 20 | (store) => store.isNotificationsEnabled, 21 | ); 22 | 23 | const [walletName, setWalletName] = React.useState( 24 | wallets[walletId].name || "", 25 | ); 26 | 27 | const onRenameWallet = async () => { 28 | useAppStore.getState().updateWallet( 29 | { 30 | name: walletName, 31 | }, 32 | walletId, 33 | ); 34 | 35 | if (!IS_EXPO_GO && isNotificationsEnabled && wallets[walletId].pushId) { 36 | const pubkey = getPubkeyFromNWCUrl( 37 | wallets[walletId].nostrWalletConnectUrl ?? "", 38 | ); 39 | if (pubkey) { 40 | await storeWalletInfo(pubkey, { 41 | name: walletName, 42 | }); 43 | } 44 | } 45 | Toast.show({ 46 | type: "success", 47 | text1: "Wallet name updated", 48 | }); 49 | router.back(); 50 | }; 51 | 52 | return ( 53 | 54 | 55 | 56 | 57 | Wallet Name 58 | 67 | 68 | 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /plugins/android/withMessageServicePlugin.js: -------------------------------------------------------------------------------- 1 | const { 2 | withAndroidManifest, 3 | withAppBuildGradle, 4 | withDangerousMod, 5 | } = require("@expo/config-plugins"); 6 | const fs = require("fs"); 7 | const path = require("path"); 8 | 9 | module.exports = function withMessagingServicePlugin(config, props = {}) { 10 | config = withMessagingService(config, props); 11 | config = withAndroidManifest(config, modifyAndroidManifest); 12 | config = withAppBuildGradle(config, modifyAppBuildGradle); 13 | return config; 14 | }; 15 | 16 | function getPackageName(config) { 17 | return config.android && config.android.package 18 | ? config.android.package 19 | : null; 20 | } 21 | 22 | function withMessagingService(config, props) { 23 | return withDangerousMod(config, [ 24 | "android", 25 | async (config) => { 26 | const projectRoot = config.modRequest.projectRoot; 27 | const srcFilePath = path.resolve(projectRoot, props.androidFMSFilePath); 28 | 29 | const packageName = getPackageName(config); 30 | if (!packageName) { 31 | throw new Error("Android package name not found in app config."); 32 | } 33 | 34 | const packagePath = packageName.replace(/\./g, path.sep); 35 | 36 | const destDir = path.join( 37 | projectRoot, 38 | "android", 39 | "app", 40 | "src", 41 | "main", 42 | "java", 43 | packagePath, 44 | ); 45 | const destFilePath = path.join(destDir, "MessagingService.kt"); 46 | 47 | fs.mkdirSync(destDir, { recursive: true }); 48 | fs.copyFileSync(srcFilePath, destFilePath); 49 | 50 | return config; 51 | }, 52 | ]); 53 | } 54 | 55 | function modifyAndroidManifest(config) { 56 | const androidManifest = config.modResults; 57 | 58 | const application = androidManifest.manifest.application?.[0]; 59 | if (!application) { 60 | throw new Error("Could not find in AndroidManifest.xml"); 61 | } 62 | 63 | if (!application.service) { 64 | application.service = []; 65 | } 66 | 67 | const serviceExists = application.service.some( 68 | (service) => service.$["android:name"] === ".MessagingService", 69 | ); 70 | 71 | if (!serviceExists) { 72 | application.service.push({ 73 | $: { 74 | "android:name": ".MessagingService", 75 | "android:exported": "false", 76 | }, 77 | "intent-filter": [ 78 | { 79 | action: [ 80 | { 81 | $: { 82 | "android:name": "com.google.firebase.MESSAGING_EVENT", 83 | }, 84 | }, 85 | ], 86 | }, 87 | ], 88 | }); 89 | } 90 | 91 | return config; 92 | } 93 | 94 | function modifyAppBuildGradle(config) { 95 | let buildGradle = config.modResults.contents; 96 | const firebaseDependency = `implementation("com.google.firebase:firebase-messaging:23.2.1")`; 97 | 98 | if (!buildGradle.includes(firebaseDependency)) { 99 | buildGradle = buildGradle.replace(/dependencies\s?{/, (match) => { 100 | return `${match}\n ${firebaseDependency}`; 101 | }); 102 | } 103 | 104 | const bcDependency = `implementation("org.bouncycastle:bcprov-jdk15to18:1.76")`; 105 | if (!buildGradle.includes(bcDependency)) { 106 | buildGradle = buildGradle.replace(/dependencies\s?{/, (match) => { 107 | return `${match}\n ${bcDependency}`; 108 | }); 109 | } 110 | 111 | config.modResults.contents = buildGradle; 112 | return config; 113 | } 114 | -------------------------------------------------------------------------------- /plugins/ios/withOpenSSLPlugin.js: -------------------------------------------------------------------------------- 1 | const { withDangerousMod } = require("@expo/config-plugins"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | 5 | const PODFILE_SNIPPET = ` 6 | pod 'OpenSSL-Universal' 7 | use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] 8 | `; 9 | 10 | module.exports = function withOpenSSLPlugin(config, props = {}) { 11 | config = withDangerousMod(config, [ 12 | "ios", 13 | async (config) => { 14 | const iosPath = path.join(config.modRequest.projectRoot, "ios"); 15 | const podfilePath = path.join(iosPath, "Podfile"); 16 | 17 | try { 18 | let podfileContent = fs.readFileSync(podfilePath, "utf8"); 19 | 20 | if (!podfileContent.includes("pod 'OpenSSL-Universal'")) { 21 | podfileContent = podfileContent.replace( 22 | /(target 'AlbyGo' do[\s\S]*?use_react_native!\([\s\S]*?\))/m, 23 | `$1\n ${PODFILE_SNIPPET}`, 24 | ); 25 | } 26 | 27 | if ( 28 | !podfileContent.includes("target 'NotificationServiceExtension' do") 29 | ) { 30 | const notificationTarget = ` 31 | target 'NotificationServiceExtension' do${PODFILE_SNIPPET}end 32 | `; 33 | podfileContent += notificationTarget; 34 | } 35 | 36 | fs.writeFileSync(podfilePath, podfileContent, "utf8"); 37 | } catch (error) { 38 | console.error("Failed to update Podfile:", error); 39 | } 40 | 41 | return config; 42 | }, 43 | ]); 44 | 45 | return config; 46 | }; 47 | -------------------------------------------------------------------------------- /services/Notifications.ts: -------------------------------------------------------------------------------- 1 | export async function registerForPushNotificationsAsync(): Promise< 2 | boolean | null 3 | > { 4 | try { 5 | const nativePushNotifications = require("~/native/notifications/service"); 6 | return await nativePushNotifications.registerForPushNotificationsAsync(); 7 | } catch (error) { 8 | console.error("Error importing push notifications logic:", error); 9 | return null; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { hairlineWidth } = require("nativewind/theme"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: "class", 6 | content: [ 7 | "./app/**/*.{js,jsx,ts,tsx}", 8 | "./components/**/*.{js,jsx,ts,tsx}", 9 | "./pages/**/*.{js,jsx,ts,tsx}", 10 | ], 11 | presets: [require("nativewind/preset")], 12 | theme: { 13 | extend: { 14 | colors: { 15 | receive: "#47A66D", 16 | send: "#E26842", 17 | border: "hsl(var(--border))", 18 | input: "hsl(var(--input))", 19 | ring: "hsl(var(--ring))", 20 | background: "hsl(var(--background))", 21 | foreground: "hsl(var(--foreground))", 22 | primary: { 23 | DEFAULT: "hsl(var(--primary))", 24 | foreground: "hsl(var(--primary-foreground))", 25 | }, 26 | secondary: { 27 | DEFAULT: "hsl(var(--secondary))", 28 | foreground: "hsl(var(--secondary-foreground))", 29 | }, 30 | destructive: { 31 | DEFAULT: "hsl(var(--destructive))", 32 | foreground: "hsl(var(--destructive-foreground))", 33 | }, 34 | muted: { 35 | DEFAULT: "hsl(var(--muted))", 36 | foreground: "hsl(var(--muted-foreground))", 37 | }, 38 | accent: { 39 | DEFAULT: "hsl(var(--accent))", 40 | foreground: "hsl(var(--accent-foreground))", 41 | }, 42 | popover: { 43 | DEFAULT: "hsl(var(--popover))", 44 | foreground: "hsl(var(--popover-foreground))", 45 | }, 46 | card: { 47 | DEFAULT: "hsl(var(--card))", 48 | foreground: "hsl(var(--card-foreground))", 49 | }, 50 | }, 51 | borderWidth: { 52 | hairline: hairlineWidth(), 53 | }, 54 | fontFamily: { 55 | sans: ["OpenRunde"], 56 | /* For some (unknown) reason the font styles aren't applied 57 | * if you use the tailwind native names 58 | */ 59 | sans2: ["OpenRunde"], 60 | medium2: ["OpenRunde-Medium"], 61 | semibold2: ["OpenRunde-Semibold"], 62 | bold2: ["OpenRunde-Bold"], 63 | }, 64 | }, 65 | }, 66 | plugins: [], 67 | }; 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "baseUrl": ".", 6 | "paths": { 7 | "~/*": ["*"] 8 | } 9 | }, 10 | "include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /webln-types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /zapstore.yaml: -------------------------------------------------------------------------------- 1 | albygo: 2 | android: 3 | identifier: com.getalby.mobile 4 | name: Alby Go 5 | license: MIT 6 | description: A simple lightning mobile wallet interface that works great with Alby Hub. 7 | builder: npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm 8 | repository: https://github.com/getAlby/go 9 | artifacts: 10 | - alby-go-v%v-android.apk 11 | --------------------------------------------------------------------------------