diff --git a/.gitignore b/.gitignore index f3da3d7d..419ee9b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,62 @@ -**/node_modules -**/build -**/dist -**/lib -!ndk-svelte-components/src/lib/ -**/.vscode -justfile -package-lock.json -**/*.js -!.eslintrc.js -!svelte.config.js -!tailwind.config.js -!postcss.config.js -**/*.d.ts -**/*.d.ts.map -!light-bolt11-decoder.d.ts -*.tgz -.DS_Store +# Monorepo +apps/*/credentials.json +apps/*/build +packages/*/build + +# Turborepo .turbo -_local_ -.svelte-kit/ -ndk/docs -.pnpm-store -docs/.vitepress/cache -docs/.vitepress/dist -.ngit + +# Expo +.expo +__generated__ +web-build + +# macOS +.DS_Store + +# Node +node_modules +npm-debug.log +yarn-error.log +package-lock.json + +# Ruby +.direnv + +# Emacs +*~ + +# Vim +.*.swp +.*.swo +.*.swn +.*.swm + +# Sublime Text +*.sublime-project +*.sublime-workspace + +# Xcode +*.pbxuser +!default.pbxuser +*.xccheckout +*.xcscmblueprint +xcuserdata + +# Android Studio +*.iml +.gradle +.idea/libraries +.idea/workspace.xml +.idea/gradle.xml +.idea/misc.xml +.idea/modules.xml +.idea/vcs.xml + +# Eclipse +.project +.settings + +# VSCode +.history/ +.vercel diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..d6e6947a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "packages/ndk"] + path = packages/ndk + url = https://github.com/nostr-dev-kit/ndk + branch = master diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..d67f3748 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..a0d49396 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2021-present Cedric van Putten + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. diff --git a/README.md b/README.md index ee929eb1..05218c7a 100644 --- a/README.md +++ b/README.md @@ -1,347 +1,19 @@ -# NDK +# Honeypot +## A NIP-60 wallet -drawing - -NDK is a [nostr](<[url](https://github.com/nostr-protocol/nostr)>) development kit that makes the experience of building Nostr-related applications, whether they are relays, clients, or anything in between, better, more reliable and overall nicer to work with than existing solutions. - -## NDK Objectives - -1. The core goal of NDK is to improve the decentralization of Nostr via intelligent conventions and data discovery features without depending on any one central point of coordination (such as large relays or centralized search providers). -2. NDK team aims to have new to nostr devs get set up, and reading a NIP-01 event within 10 minutes. -3. NDK's objective is to serve prospective, and current nostr devs as clients. If you have friction with the NDK developer experience, please open issues, and ask for help from the NDK team! Devs are encouraged to search through existing, and/or create new github issues when experiencing friction with NDK. - -## Installation - -```sh -npm add @nostr-dev-kit/ndk -``` - -## Debugging - -NDK uses the `debug` package to assist in understanding what's happening behind the hood. If you are building a package -that runs on the server define the `DEBUG` envionment variable like - -``` -export DEBUG='ndk:*' -``` - -or in the browser enable it by writing in the DevTools console - -``` -localStorage.debug = 'ndk:*' -``` - -## Support - -- [documentation](https://ndk.fyi/docs) - -## Features - -- [x] NIP-01 -- [x] Caching adapters - - Server-side - - [x] [Redis](https://github.com/nostr-dev-kit/ndk-cache-redis) - - [ ] In-memory - - Client-side - - [ ] LocalStorage - - [x] IndexD ([Dexie](https://github.com/nostr-dev-kit/ndk-cache-dexie)) -- [~] NIP-04: Encryption support -- [x] NIP-18: Repost -- [ ] ~~NIP-26~~ Won't add / NIP-26 is dead -- [x] NIP-42: Relay authentication -- [x] NIP-57: Zaps - - [x] LUD06 - - [x] LUD16 -- [ ] NIP-65: Contacts' Relay list -- [x] NIP-89: Application Handlers -- [x] NIP-90: Data Vending Machines -- Subscription Management - - [x] Auto-grouping queries - - [x] Auto-closing subscriptions -- Signing Adapters - - [x] Private key - - [x] NIP-07 - - [!] ~~NIP-26~~ Won't add / NIP-26 is dead - - [x] NIP-46 - - [x] Permission tokens - - [x] OAuth flow -- Relay discovery - - [x] Outbox-model (NIP-65) - - [ ] Implicit relays discovery following pubkey usage - - [ ] Implicit relays discovery following `t` tag usage - - [x] Explicit relays blacklist -- [ ] nostr-tools/SimplePool drop-in replacement interface -- [x] NIP-47: Nostr Wallet Connect -- [x] NIP-96: Media Uploads - - [x] XMLHttpRequest (for progress reporting) - - [x] Fetch API - -## Real-world uses of NDK - -See [REFERENCES.md](./REFERENCES.md) for a list of projects using NDK to see how others are using it. - -## Instantiate an NDK instance - -You can pass an object with several options to a newly created instance of NDK. - -- `explicitRelayUrls` โ€“ย an array of relay URLs. -- `signer` - an instance of a [signer](#signers). -- `cacheAdapter` - an instance of a [Cache Adapter](#caching) -- `debug` - Debug instance to use for logging. Defaults to `debug("ndk")`. - -```ts -// Import the package -import NDK from "@nostr-dev-kit/ndk"; -import "websocket-polyfill"; - -// Create a new NDK instance with explicit relays -const ndk = new NDK({ - explicitRelayUrls: ["wss://a.relay", "wss://another.relay"], -}); -``` - -If the signer implements the `getRelays()` method, NDK will use the relays returned by that method as the explicit relays. - -```ts -// Import the package -import NDK, { NDKNip07Signer } from "@nostr-dev-kit/ndk"; - -// Create a new NDK instance with just a signer (provided the signer implements the getRelays() method) -const nip07signer = new NDKNip07Signer(); -const ndk = new NDK({ signer: nip07signer }); -``` - -Note: In normal client use, it's best practice to instantiate NDK as a singleton class. [See more below](#architecture-decisions--suggestions). - -## Connecting - -After you've instatiated NDK, you need to tell it to connect before you'll be able to interact with any relays. - -```ts -// Import the package -import NDK from "@nostr-dev-kit/ndk"; - -// Create a new NDK instance with explicit relays -const ndk = new NDK({ - explicitRelayUrls: ["wss://a.relay", "wss://another.relay"], -}); -// Now connect to specified relays -await ndk.connect(); -``` - -## Signers - -NDK uses signers _optionally_ passed in to sign events. Note that it is possible to use NDK without signing events (e.g. [to get someone's profile](https://github.com/nostr-dev-kit/ndk-cli/blob/master/src/commands/profile.ts)). - -Signing adapters can be passed in when NDK is instantiated or later during runtime. - -### Using a NIP-07 browser extension (e.g. Alby, nos2x) - -Instatiate NDK with a NIP-07 signer - -```ts -// Import the package, NIP-07 signer and NDK event -import NDK, { NDKEvent, NDKNip07Signer } from "@nostr-dev-kit/ndk"; - -const nip07signer = new NDKNip07Signer(); -const ndk = new NDK({ signer: nip07signer }); -``` - -NDK can now ask for permission, via their NIP-07 extension, to... - -**Read the user's public key** - -```ts -nip07signer.user().then(async (user) => { - if (!!user.npub) { - console.log("Permission granted to read their public key:", user.npub); - } -}); -``` - -**Sign & publish events** - -```ts -const ndkEvent = new NDKEvent(ndk); -ndkEvent.kind = 1; -ndkEvent.content = "Hello, world!"; -ndkEvent.publish(); // This will trigger the extension to ask the user to confirm signing. -``` - - - -## Caching - -NDK provides database-agnostic caching functionality out-of-the-box to improve the performance of your application and reduce load on relays. - -NDK will eventually allow you to use multiple caches simultaneously and allow for selective storage of data in the cache store that makes the most sense for your application. - -### Where to look is more important that long-term storage - -The most important data to cache is _where_ a user or note might be found. UX suffers profoundly when this type of data cannot be found. By design, the Nostr protocol leaves breadcrumbs of where a user or note might be found and NDK does it's best to store this data automatically and use it when you query for events. - -### Instantiating and using a cache adapter - -```ts -const redisAdapter = new RedisAdapter(redisUrl); -const ndk = new NDK({ cacheAdapter: redisAdapter }); +# Installation +```bash +git clone --recurse-submodules https://github.com/pablof7z/nutsack +cd nutsack +pnpm install ``` -## Groupable queries - -Clients often need to load data (e.g. profile data) from individual components at once (e.g. initial page render). This typically causes multiple subscriptions to be submitted fetching the same information and causing poor performance or getting rate-limited/maxed out by relays. - -NDK implements a convenient subscription model, _buffered queries_, where a named subscription will be created after a customizable amount of time, so that multiple components can append queries. - -```ts -// Component 1 -ndk.subscribe({ kinds: [0], authors: ["pubkey-1"] }); - -// Component 2 -ndk.subscribe({ kinds: [0], authors: ["pubkey-2"] }); -``` - -In this example, NDK will wait 100ms (default `groupableDelay`) before creating a subscription with the filter: - -```ts -{kinds: [0], authors: ['pubkey-1', 'pubkey-2'] } -``` - -## Intelligent relay selection - -When a client submits a request through NDK, NDK will calculate which relays are most likely able to satisfy this request. - -Queries submitted by the client might be broken into different queries if NDK computes different relays. +This is a monorepo -- applications are in `apps` -For example, say npub-A follows npub-B and npub-C. If the NDK client uses: +# Author +[@pablof7z](https://njump.me/f7z.io) -```ts -const ndk = new NDK({ explicitRelayUrls: ["wss://nos.lol"] }); -const npubA = ndk.getUser("npub-A"); -const feedEvents = await npubA.feed(); -``` - -This would result in the following request: - -```json -{ "kinds": [1], "authors": ["npub-B", "npub-C"] } -``` - -But if NDK has observed that `npub-B` tends to write to `wss://userb.xyz` and -`npub-C` tends to write to `wss://userc.io`, NDK will instead send the following queries. - -```json -// to npub-A's explicit relay wss://nos.lol *if* npub-B and npub-C have been seen on that relay -{ "kinds": [1], "authors": [ "npub-B", "npub-C" ] } - -// to wss://userb.xyz -{ "kinds": [1], "authors": [ "npub-B" ] } - -// to wss://userc.io -{ "kinds": [1], "authors": [ "npub-C" ] } -``` - -## Auto-closing subscriptions - -Often, clients need to fetch data but don't need to maintain an open connection to the relay. This is true of profile metadata requests especially. -_NDK defaults to having the `closeOnEose` flag set to `false`; if you want your subscription to close after `EOSE`, you should set it to `true`._ - -- The `closeOnEose` flag will make the connection close immediately after EOSE is seen. - -```ts -ndk.subscribe({ kinds: [0], authors: ["..."] }, { closeOnEose: true }); -``` - -## Convenience methods - -NDK implements several conveience methods for common queries. - -### Instantiate a user by npub or hex pubkey - -This is a handy method for instantiating a new `NDKUser` and associating the current NDK instance with that user for future calls. - -```ts -const pablo = ndk.getUser({ - npub: "npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft", -}); - -const jeff = ndk.getUser({ - pubkey: "1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef", -}); -``` - -### Fetch a user's profile and publish updates - -You can easily fetch a user's profile data from `kind:0` events on relays. Calling `.fetchProfile()` will update the `profile` attribute on the user object instead of returning the profile directly. NDK then makes it trivial to update values and publish those updates back to relays. - -```ts -const pablo = ndk.getUser({ - npub: "npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft", -}); -await pablo.fetchProfile(); - -const pabloFullProfile = pablo.profile; - -pablo.profile.name = "Pablo"; -await pablo.publish(); // Triggers signing via signer -``` - -### Finding a single event or all events matching a filter - -You can fetch the first event or all events that match a given set of filters. - -```ts -// Create a filter -const filter: NDKFilter = { kinds: [1], authors: [hexpubkey1, hexpubkey2] }; - -// Will return only the first event -event = await ndk.fetchEvent(filter); - -// Will return all found events -events = await ndk.fetchEvents(filter); -``` - -### Creating & publishing events - -```ts -const ndk = new NDK({ explicitRelayUrls, signer }); -const event = new NDKEvent(ndk); -event.kind = 1; -event.content = "PV Nostr! ๐Ÿค™๐Ÿผ"; -await ndk.publish(event); -``` - -### Reacting to an event - -```ts -// Find the first event from @jack, and react/like it. -const jack = await ndk.getUserFromNip05("jack@cashapp.com"); -const event = await ndk.fetchEvent({ authors: [jack.pubkey] })[0]; -await event.react("๐Ÿค™"); -``` - -### Zap an event - -```ts -// Find the first event from @jack, and zap it. -const jack = await ndk.getUserFromNip05("jack@cashapp.com"); -const event = await ndk.fetchEvent({ authors: [jack.pubkey] })[0]; -await ndk.zap(event, 1337, "Zapping your post!"); // Returns a bolt11 payment request -``` +# License +MIT -## Architecture decisions & suggestions -- Users of NDK should instantiate a single NDK instance. -- That instance tracks state with all relays connected, explicit and otherwise. -- All relays are tracked in a single pool that handles connection errors/reconnection logic. -- RelaySets are assembled ad-hoc as needed depending on the queries set, although some RelaySets might be long-lasting, like the `explicitRelayUrls` specified by the user. -- RelaySets are always a subset of the pool of all available relays. diff --git a/apps/mobile/.easignore b/apps/mobile/.easignore new file mode 100644 index 00000000..6f85c34a --- /dev/null +++ b/apps/mobile/.easignore @@ -0,0 +1,26 @@ +# Expo +android +yarn.lock +/android +/ios +android +__generated__ +node_modules +.expo/* +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + +# macOS +.DS_Store + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/apps/mobile/.env b/apps/mobile/.env new file mode 100644 index 00000000..e69de29b diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 00000000..d9d3fe4f --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,28 @@ +# Expo +android +yarn.lock +/android +/ios +android +__generated__ +node_modules +.expo/* +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + +# macOS +.DS_Store + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli + +*.apk diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 00000000..7dd7be5e --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,91 @@ +{ + "expo": { + "name": "Honeypot", + "slug": "honeypot", + "version": "0.1.0", + "scheme": "honeypot", + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-camera", + { + "cameraPermission": "Allow Threads to access your camera", + "microphonePermission": "Allow Threads to access your microphone", + "recordAudioAndroid": true + } + ], + [ + "expo-secure-store", + { + "faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data." + } + ], + [ + "expo-splash-screen", + { + "backgroundColor": "#232323", + "image": "./assets/splash.png", + "dark": { + "image": "./assets/splash.png", + "backgroundColor": "#000000" + }, + "imageWidth": 200, + "ios": { + // You may want to use regular full screen splash on iOS + "image": "./assets/splash.png", // Full screen transparent splash png + "enableFullScreenImage_legacy": true, + "resizeMode": "contain" + } + } + ] + ], + "experiments": { + "typedRoutes": true, + "tsconfigPaths": true + }, + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "splash": { + "image": "./assets/splash.png", + "contentFit": "contain", + "backgroundColor": "#ffffff" + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": false, + "bundleIdentifier": "com.pablof7z.honeypot", + "config": { + "usesNonExemptEncryption": false + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "package": "com.pablof7z.honeypot", + "permissions": [ + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", + "android.permission.MODIFY_AUDIO_SETTINGS" + ] + }, + "owner": "sanityisland", + "extra": { + "router": { + "origin": false + }, + "eas": { + "projectId": "d61f508c-f0c5-4c13-b1ce-77c5972ee63d" + } + } + } +} diff --git a/apps/mobile/app/(awallet)/(walletSettings)/_layout.tsx b/apps/mobile/app/(awallet)/(walletSettings)/_layout.tsx new file mode 100644 index 00000000..848b174f --- /dev/null +++ b/apps/mobile/app/(awallet)/(walletSettings)/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from "expo-router"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + ) +} diff --git a/apps/mobile/app/(awallet)/(walletSettings)/index.tsx b/apps/mobile/app/(awallet)/(walletSettings)/index.tsx new file mode 100644 index 00000000..6a99ca0f --- /dev/null +++ b/apps/mobile/app/(awallet)/(walletSettings)/index.tsx @@ -0,0 +1,148 @@ +import { useNDK, useNDKSession } from '@nostr-dev-kit/ndk-mobile'; +import { Icon, MaterialIconName } from '@roninoss/icons'; +import { useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Platform, View } from 'react-native'; + +import { LargeTitleHeader } from '~/components/nativewindui/LargeTitleHeader'; +import { ESTIMATED_ITEM_HEIGHT, List, ListDataItem, ListItem, ListRenderItemInfo, ListSectionHeader } from '~/components/nativewindui/List'; +import { Text } from '~/components/nativewindui/Text'; +import { cn } from '~/lib/cn'; +import { useColorScheme } from '~/lib/useColorScheme'; +import { router } from 'expo-router'; +import { NDKCashuWallet } from '@nostr-dev-kit/ndk-wallet'; + +export default function WalletSettings() { + const { currentUser } = useNDK(); + const { activeWallet, balances, setActiveWallet } = useNDKSession(); + const [syncing, setSyncing] = useState(false); + const { colors } = useColorScheme(); + console.log('balances', balances); + + useEffect(() => { + console.log('use effect balances', balances); + }, [balances]); + + const forceSync = async () => { + setSyncing(true); + const res = await (activeWallet as NDKCashuWallet).checkProofs(); + console.log('forceSync', res); + setSyncing(false); + } + + const data = useMemo(() => { + const opts = [ + { + id: '2', + title: 'Relays', + leftView: , + onPress: () => router.push('/(awallet)/(walletSettings)/relays') + }, + { + id: '3', + title: 'Mints', + leftView: , + onPress: () => router.push('/(awallet)/(walletSettings)/mints'), + }, + + 'gap 0', + + { + id: '4', + title: 'Force-Sync', + onPress: forceSync, + rightView: syncing ? : null + } + ]; + + if (activeWallet instanceof NDKCashuWallet && (activeWallet as NDKCashuWallet)?.warnings.length > 0) { + opts.push('Warnings') + + for (const warning of (activeWallet as NDKCashuWallet)?.warnings) { + opts.push({ + id: warning.event?.id ?? Math.random().toString(), + leftView: , + title: warning.msg, + subTitle: warning.relays?.map((r) => r.url).join(', '), + }); + } + + const pendingDeposits = activeWallet.depositMonitor.deposits.size; + + if (pendingDeposits > 0) { + opts.push({ + id: '5', + title: 'Pending deposits', + subTitle: pendingDeposits.toString(), + }); + } + } + + return opts; + }, [currentUser, activeWallet, balances]); + + return ( + <> + + + ); +} + +function renderItem(info: ListRenderItemInfo) { + if (typeof info.item === 'string') { + return ; + } + return ( + + {info.item.rightText && ( + + {info.item.rightText} + + )} + {info.item.badge && ( + + + {info.item.badge} + + + )} + + + } + {...info} + onPress={() => info.item.onPress?.()} + /> + ); +} + +function ChevronRight() { + const { colors } = useColorScheme(); + return ; +} + +export function IconView({ className, name, children }: { className?: string; name?: MaterialIconName; children?: React.ReactNode }) { + return ( + + + {name ? : children} + + + ); +} + +function keyExtractor(item: (Omit & { id: string }) | string) { + return typeof item === 'string' ? item : item.id; +} diff --git a/apps/mobile/app/(awallet)/(walletSettings)/mints.tsx b/apps/mobile/app/(awallet)/(walletSettings)/mints.tsx new file mode 100644 index 00000000..96ca9369 --- /dev/null +++ b/apps/mobile/app/(awallet)/(walletSettings)/mints.tsx @@ -0,0 +1,198 @@ +import { Icon, MaterialIconName } from '@roninoss/icons'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Dimensions, View } from 'react-native'; + +import { LargeTitleHeader } from '~/components/nativewindui/LargeTitleHeader'; +import { + ESTIMATED_ITEM_HEIGHT, + List, + ListDataItem, + ListItem, + ListRenderItemInfo, + ListSectionHeader, +} from '~/components/nativewindui/List'; +import { Text } from '~/components/nativewindui/Text'; +import { cn } from '~/lib/cn'; +import { useColorScheme } from '~/lib/useColorScheme'; +import { TextInput, TouchableOpacity } from 'react-native-gesture-handler'; +import { router, Tabs } from 'expo-router'; +import { CashuMint, GetInfoResponse } from '@cashu/cashu-ts'; +import { NDKCashuMintList, useNDKSession, useSubscribe } from '@nostr-dev-kit/ndk-mobile'; +import { useNDK } from '@nostr-dev-kit/ndk-mobile'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { NDKCashuWallet } from '@nostr-dev-kit/ndk-wallet'; + +export default function MintsScreen() { + const { ndk } = useNDK(); + const { activeWallet } = useNDKSession(); + const [ searchText, setSearchText ] = useState(null); + const [url, setUrl] = useState(""); + const [mints, setMints] = useState(activeWallet?.mints??[]); + + const filter = useMemo(() => ([{ kinds: [38172], limit: 50 }]), [1]); + const opts = useMemo(() => ({ groupable: false, closeOnEose: true, subId: 'mints' }), []); + const { events: mintList } = useSubscribe({ filters: filter, opts }); + const [mintInfos, setMintInfos] = useState>({}); + + const insets = useSafeAreaInsets(); + + const addFn = () => { + console.log("addFn", url) + try { + const uri = new URL(url) + if (!['https:', 'http:'].includes(uri.protocol)) { + alert("Invalid protocol") + return; + } + setMints([...mints, url]) + setUrl(""); + } catch (e) { + console.log("addFn", e) + alert("Invalid URL") + } + }; + + const data = useMemo(() => { + if (!ndk || !activeWallet) return [] + const regexp = new RegExp(/${searchText}/i) + + const m = mints.map(mint => ({ + id: mint, + title: mint, + removeFn: () => removeMint(mint), + })) + .filter(item => (searchText??'').trim().length === 0 || item.title.match(regexp!)) + + m.push({ id: 'add', addFn: addFn, set: setUrl }) + + for (const event of mintList) { + const url = event.tagValue("u"); + if (!url || mints.includes(url)) continue; + const niceUrl = new URL(url).hostname; + + // if (mintInfos[url] === undefined) { + // setMintInfos({...mintInfos, [url]: null}); + // CashuMint.getInfo(url).then(info => setMintInfos({...mintInfos, [url]: info})); + // } + + m.push({ + id: url, + title: mintInfos[url]?.name ?? niceUrl, + subTitle: url, + addFn: () => addMint(url), + }) + } + + return m; + }, [mintList, mints, searchText, mintInfos]); + + const save = async () => { + if (!(activeWallet instanceof NDKCashuWallet)) return; + + activeWallet.mints = mints; + await activeWallet.getP2pk(); + activeWallet.publish().then(() => { + const mintList = new NDKCashuMintList(ndk); + mintList.mints = mints; + mintList.relays = activeWallet.relays; + mintList.p2pk = activeWallet.p2pk; + mintList.publish(); + router.back() + }) + } + + const addMint = (url: string) => { + setMints([...mints, url]) + } + + const removeMint = (url: string) => { + setMints(mints.filter(u => u !== url)); + } + + + return ( + + ( + + Save + + )} + /> + + + + + ); +} + +function renderItem(info: ListRenderItemInfo) { + if (info.item.id === 'add') { + return ( + + Add + + )} + {...info} + > + + + ) + } else if (info.item.kind === 38172) { + } + return ( + + + Add + + + ) : ( + + + Remove + + + )) + )} + {...info} + /> + ); +} + +function keyExtractor(item: (Omit & { id: string }) | string) { + return typeof item === 'string' ? item : item.id; +} \ No newline at end of file diff --git a/apps/mobile/app/(awallet)/(walletSettings)/relays.tsx b/apps/mobile/app/(awallet)/(walletSettings)/relays.tsx new file mode 100644 index 00000000..ebfcd228 --- /dev/null +++ b/apps/mobile/app/(awallet)/(walletSettings)/relays.tsx @@ -0,0 +1,181 @@ +import { useNDK, useNDKSession } from '@nostr-dev-kit/ndk-mobile'; +import { Icon } from '@roninoss/icons'; +import { useMemo, useState } from 'react'; +import { View } from 'react-native'; +import { LargeTitleHeader } from '~/components/nativewindui/LargeTitleHeader'; +import { ESTIMATED_ITEM_HEIGHT, List, ListDataItem, ListItem, ListRenderItemInfo, ListSectionHeader } from '~/components/nativewindui/List'; +import { Text } from '~/components/nativewindui/Text'; +import { cn } from '~/lib/cn'; +import { useColorScheme } from '~/lib/useColorScheme'; +import { NDKRelay, NDKRelayStatus } from '@nostr-dev-kit/ndk-mobile'; +import * as SecureStore from 'expo-secure-store'; +import { TextInput, TouchableOpacity } from 'react-native-gesture-handler'; +import { router } from 'expo-router'; +import { NDKCashuWallet } from '@nostr-dev-kit/ndk-wallet'; + +const CONNECTIVITY_STATUS_COLORS: Record = { + [NDKRelayStatus.RECONNECTING]: '#f1c40f', + [NDKRelayStatus.CONNECTING]: '#f1c40f', + [NDKRelayStatus.DISCONNECTED]: '#aa4240', + [NDKRelayStatus.DISCONNECTING]: '#aa4240', + [NDKRelayStatus.CONNECTED]: '#66cc66', + [NDKRelayStatus.FLAPPING]: '#2ecc71', + [NDKRelayStatus.AUTHENTICATING]: '#3498db', + [NDKRelayStatus.AUTHENTICATED]: '#e74c3c', + [NDKRelayStatus.AUTH_REQUESTED]: '#e74c3c', +} as const; + +function RelayConnectivityIndicator({ relay }: { relay: NDKRelay }) { + const color = CONNECTIVITY_STATUS_COLORS[relay.status]; + + return ( + + ); +} + +export default function WalletRelayScreen() { + const { ndk } = useNDK(); + const { activeWallet } = useNDKSession(); + const [searchText, setSearchText] = useState(null); + const [relays, setRelays] = useState(Array.from((activeWallet as NDKCashuWallet).relaySet.relays.values())); + const [url, setUrl] = useState(''); + + const addFn = () => { + console.log({ url }); + try { + const uri = new URL(url); + if (!['wss:', 'ws:'].includes(uri.protocol)) { + alert('Invalid protocol'); + return; + } + const relay = ndk?.addExplicitRelay(url); + if (relay) setRelays([...relays, relay]); + setUrl(''); + } catch (e) { + alert('Invalid URL'); + } + }; + + const data = useMemo(() => { + let r: NDKRelay[] = relays; + + if (searchText) { + r = r.filter((relay) => relay.url.includes(searchText)); + } + + return r + .map((relay: NDKRelay) => ({ + id: relay.url, + title: relay.url, + rightView: ( + + + + ), + })) + .filter((item) => (searchText ?? '').trim().length === 0 || item.title.match(searchText!)); + }, [searchText, relays]); + + function save() { + SecureStore.setItemAsync('relays', relays.map((r) => r.url).join(',')); + router.back(); + } + + return ( + <> + ( + + Save + + )} + /> + + + ); +} + +function renderItem(info: ListRenderItemInfo) { + if (info.item.id === 'add') { + return ( + + Add + + } + {...info}> + + + ); + } else if (typeof info.item === 'string') { + return ; + } + return ( + + {info.item.rightText && ( + + {info.item.rightText} + + )} + {info.item.badge && ( + + + {info.item.badge} + + + )} + + + ) + } + {...info} + onPress={() => console.log('onPress')} + /> + ); +} + +function ChevronRight() { + const { colors } = useColorScheme(); + return ; +} + +function keyExtractor(item: (Omit & { id: string }) | string) { + return typeof item === 'string' ? item : item.id; +} diff --git a/apps/mobile/app/(awallet)/_layout.tsx b/apps/mobile/app/(awallet)/_layout.tsx new file mode 100644 index 00000000..fd55ef93 --- /dev/null +++ b/apps/mobile/app/(awallet)/_layout.tsx @@ -0,0 +1,71 @@ +import { useColorScheme } from "@/lib/useColorScheme"; +import { useNDK, useNDKSession } from "@nostr-dev-kit/ndk-mobile"; +import { Redirect, Tabs } from "expo-router"; +import { Bolt, Calendar, PieChart, QrCode, SettingsIcon } from "lucide-react-native"; + +export default function Layout({ children }: { children: React.ReactNode }) { + const { colors } = useColorScheme(); + const { currentUser } = useNDK(); + const { activeWallet } = useNDKSession(); + + if (!currentUser) { + return + } + + if (!activeWallet) { + return + } + + return ( + + + }} + /> + + + }} + /> + + , + }} + /> + + , + }} + /> + + + }} + /> + + ) +} \ No newline at end of file diff --git a/apps/mobile/app/(awallet)/index.tsx b/apps/mobile/app/(awallet)/index.tsx new file mode 100644 index 00000000..95f9b1d0 --- /dev/null +++ b/apps/mobile/app/(awallet)/index.tsx @@ -0,0 +1,139 @@ +import { View, Text, SafeAreaView, TouchableOpacity, ScrollView } from "react-native"; +import { NDKCashuMintList, NDKEvent, NDKKind, NDKNutzap, NDKPaymentConfirmation, NDKUser, NDKZapper, NDKZapSplit, useNDK, useNDKSession, useNDKSessionEventKind, useNDKSessionEvents, useSubscribe, useUserProfile } from "@nostr-dev-kit/ndk-mobile"; +import { NDKCashuWallet, NDKNWCWallet, NDKWallet, NDKWalletBalance, NDKWalletChange } from "@nostr-dev-kit/ndk-wallet"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { router, Stack, Tabs } from "expo-router"; +import { formatMoney } from "@/utils/bitcoin"; +import { List, ListItem } from "@/components/nativewindui/List"; +import { cn } from "@/lib/cn"; +import { BlurView } from "expo-blur"; +import { Button } from "@/components/nativewindui/Button"; +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Bolt, BookDown, ChevronDown, Cog, Eye, Settings, Settings2, User2, ZoomIn } from "lucide-react-native"; +import * as User from '@/components/ui/user'; +import { useColorScheme } from "@/lib/useColorScheme"; +import TransactionHistory from "@/components/TransactionList/List"; +import WalletBalance from "@/components/ui/wallet/WalletBalance"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +function WalletNWC({ wallet }: { wallet: NDKNWCWallet }) { + const [info, setInfo] = useState | null>(null); + wallet.getInfo().then((info) => { + console.log('info', info); + setInfo(info) + }); + + return + {JSON.stringify(info)} + ; +} + +function WalletNip60({ wallet }: { wallet: NDKCashuWallet }) { + return ( + + + + ); +} + +export default function WalletScreen() { + const { ndk, currentUser } = useNDK(); + const { activeWallet, balances, setActiveWallet } = useNDKSession(); + const mintList = useNDKSessionEventKind(NDKCashuMintList, NDKKind.CashuMintList, { create: true }); + + const isNutzapWallet = useMemo(() => { + if (!(activeWallet instanceof NDKCashuWallet)) return false; + if (!mintList) return false; + return mintList.p2pk === activeWallet.p2pk; + }, [activeWallet, mintList]); + + const setNutzapWallet = async () => { + try { + if (!mintList || !(activeWallet instanceof NDKCashuWallet)) return; + mintList.ndk = ndk; + console.log('setNutzapWallet', activeWallet.event.rawEvent(), mintList.rawEvent()); + mintList.p2pk = activeWallet.p2pk; + mintList.mints = activeWallet.mints; + mintList.relays = activeWallet.relays; + await mintList.sign(); + mintList.publishReplaceable(); + console.log('mintList', JSON.stringify(mintList.rawEvent(), null, 2)); + } catch (e) { + console.error('error', e); + } + } + + const inset = useSafeAreaInsets(); + + return ( + <> + , + headerTitle: "Wallet", + headerLeft: () => + }} + /> + + + + {/* {!isNutzapWallet && ( + + )} */} + + {balances.length > 0 && {}} />} +