Merge branch 'zapSupport' into 'main'

Implement Zap + NWC Wallet support

See merge request soapbox-pub/mkstack!11
This commit is contained in:
Chad Curtis 2025-07-26 00:12:25 +00:00
commit 7473c794cc
14 changed files with 2026 additions and 21 deletions

View File

@ -18,6 +18,7 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
- `/src/components/`: UI components including NostrProvider for Nostr integration
- `/src/components/ui/`: shadcn/ui components (48+ components available)
- `/src/components/auth/`: Authentication-related components (LoginArea, LoginDialog, etc.)
- Zap components: `ZapButton`, `ZapDialog`, `WalletModal` for Lightning payments
- `/src/hooks/`: Custom hooks including:
- `useNostr`: Core Nostr protocol integration
- `useAuthor`: Fetch user profile data by pubkey
@ -31,9 +32,13 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3.
- `useLoggedInAccounts`: Manage multiple accounts
- `useLoginActions`: Authentication actions
- `useIsMobile`: Responsive design helper
- `useZaps`: Lightning zap functionality with payment processing
- `useWallet`: Unified wallet detection (WebLN + NWC)
- `useNWC`: Nostr Wallet Connect connection management
- `useNWCContext`: Access NWC context provider
- `/src/pages/`: Page components used by React Router (Index, NotFound)
- `/src/lib/`: Utility functions and shared logic
- `/src/contexts/`: React context providers (AppContext)
- `/src/contexts/`: React context providers (AppContext, NWCContext)
- `/src/test/`: Testing utilities including TestApp component
- `/public/`: Static assets
- `App.tsx`: Main app component with provider setup
@ -494,6 +499,8 @@ The `LoginArea` component handles all the login-related UI and interactions, inc
`LoginArea` displays both "Log in" and "Sign Up" buttons when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like `flex` (to make it a block element) or `w-full`. If it is left as inline-flex, it's recommended to set a max width.
**Important**: Social applications should include a profile menu button in the main interface (typically in headers/navigation) to provide access to account settings, profile editing, and logout functionality. Don't only show `LoginArea` in logged-out states.
### `npub`, `naddr`, and other Nostr addresses
Nostr defines a set of bech32-encoded identifiers in NIP-19. Their prefixes and purposes:

396
package-lock.json generated
View File

@ -8,6 +8,8 @@
"name": "mkstack",
"version": "0.0.0",
"dependencies": {
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.4",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.8",
@ -50,6 +52,7 @@
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
@ -61,6 +64,7 @@
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.3",
"webln": "^0.3.2",
"zod": "^3.25.71"
},
"devDependencies": {
@ -71,6 +75,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.5.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.5.0",
@ -993,6 +998,45 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.6.tgz",
"integrity": "sha512-jks/bficUPQ9nn7GvXvHtlQIPudW7Wx8CrlZoY8bhxgeobNxlQan8DclUJuYF2loYRrGpfrhCIZZspXYysiVGg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@getalby/lightning-tools": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz",
"integrity": "sha512-8kBvENBTMh541VjGKhw3I29+549/C02gLSh3AQaMfoMNSZaMxfQW+7dcMcc7vbFaCKEcEe18ST5bUveTRBuXCQ==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "lightning",
"url": "lightning:hello@getalby.com"
}
},
"node_modules/@getalby/sdk": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-5.1.1.tgz",
"integrity": "sha512-t/kg2ljPx86qRYKqEVc5VYhDICFKtVPRlQKIz5cI/AqOLYVguLJz1AkQlDBaiOz2PW5FxoyGlLkTGmX7ONHH/Q==",
"license": "MIT",
"dependencies": {
"@getalby/lightning-tools": "^5.1.2",
"nostr-tools": "2.15.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "lightning",
"url": "lightning:hello@getalby.com"
}
},
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
@ -3448,6 +3492,15 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/chrome": {
"version": "0.0.74",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.74.tgz",
"integrity": "sha512-hzosS5CkQcIKCgxcsV2AzbJ36KNxG/Db2YEN/erEu7Boprg+KpMDLBQqKFmSo+JkQMGqRcicUyqCowJpuT+C6A==",
"license": "MIT",
"dependencies": {
"@types/filesystem": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@ -3517,6 +3570,21 @@
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"license": "MIT"
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -3541,6 +3609,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "18.3.20",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz",
@ -4229,6 +4307,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@ -4351,6 +4438,72 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -4640,6 +4793,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
@ -4703,6 +4865,12 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@ -5251,6 +5419,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@ -5987,9 +6164,9 @@
}
},
"node_modules/nostr-tools": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.13.0.tgz",
"integrity": "sha512-A1arGsvpULqVK0NmZQqK1imwaCiPm8gcG/lo+cTax2NbNqBEYsuplbqAFdVqcGHEopmkByYbTwF76x25+oEbew==",
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.15.0.tgz",
"integrity": "sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
@ -5997,9 +6174,7 @@
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"
},
"optionalDependencies": {
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
@ -6015,8 +6190,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/nwsapi": {
"version": "2.2.20",
@ -6093,6 +6267,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -6129,7 +6312,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -6229,6 +6411,15 @@
"pathe": "^2.0.1"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@ -6472,6 +6663,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -6762,6 +6970,21 @@
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@ -6914,6 +7137,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -7965,6 +8194,15 @@
"node": ">=12"
}
},
"node_modules/webln": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/webln/-/webln-0.3.2.tgz",
"integrity": "sha512-YYT83aOCLup2AmqvJdKtdeBTaZpjC6/JDMe8o6x1kbTYWwiwrtWHyO//PAsPixF3jwFsAkj5DmiceB6w/QSe7Q==",
"license": "MIT",
"dependencies": {
"@types/chrome": "^0.0.74"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
@ -8029,6 +8267,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -8183,6 +8427,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
@ -8195,6 +8445,134 @@
"node": ">= 14"
}
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

@ -10,6 +10,8 @@
"deploy": "npm run build && npx -y nostr-deploy-cli deploy --skip-setup"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.4",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.8",
@ -52,6 +54,7 @@
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
@ -63,6 +66,7 @@
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.3",
"webln": "^0.3.2",
"zod": "^3.25.71"
},
"devDependencies": {
@ -73,6 +77,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.5.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.5.0",

View File

@ -11,6 +11,7 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { NostrLoginProvider } from '@nostrify/react/login';
import { AppProvider } from '@/components/AppProvider';
import { NWCProvider } from '@/contexts/NWCContext';
import { AppConfig } from '@/contexts/AppContext';
import AppRouter from './AppRouter';
@ -49,13 +50,15 @@ export function App() {
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
<NWCProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>

View File

@ -0,0 +1,395 @@
import { useState, forwardRef } from 'react';
import { Wallet, Plus, Trash2, Zap, Globe, WalletMinimal, CheckCircle, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
DrawerClose,
} from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useToast } from '@/hooks/useToast';
import { useIsMobile } from '@/hooks/useIsMobile';
import type { NWCConnection, NWCInfo } from '@/hooks/useNWC';
interface WalletModalProps {
children?: React.ReactNode;
className?: string;
}
// Extracted AddWalletContent to prevent re-renders
const AddWalletContent = forwardRef<HTMLDivElement, {
alias: string;
setAlias: (value: string) => void;
connectionUri: string;
setConnectionUri: (value: string) => void;
}>(({ alias, setAlias, connectionUri, setConnectionUri }, ref) => (
<div className="space-y-4 px-4" ref={ref}>
<div>
<Label htmlFor="alias">Wallet Name (optional)</Label>
<Input
id="alias"
placeholder="My Lightning Wallet"
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
</div>
<div>
<Label htmlFor="connection-uri">Connection URI</Label>
<Textarea
id="connection-uri"
placeholder="nostr+walletconnect://..."
value={connectionUri}
onChange={(e) => setConnectionUri(e.target.value)}
rows={3}
/>
</div>
</div>
));
AddWalletContent.displayName = 'AddWalletContent';
// Extracted WalletContent to prevent re-renders
const WalletContent = forwardRef<HTMLDivElement, {
hasWebLN: boolean;
isDetecting: boolean;
hasNWC: boolean;
connections: NWCConnection[];
connectionInfo: Record<string, NWCInfo>;
activeConnection: string | null;
handleSetActive: (cs: string) => void;
handleRemoveConnection: (cs: string) => void;
setAddDialogOpen: (open: boolean) => void;
}>(({
hasWebLN,
isDetecting,
hasNWC,
connections,
connectionInfo,
activeConnection,
handleSetActive,
handleRemoveConnection,
setAddDialogOpen
}, ref) => (
<div className="space-y-6 px-4 pb-4" ref={ref}>
{/* Current Status */}
<div className="space-y-3">
<h3 className="font-medium">Current Status</h3>
<div className="grid gap-3">
{/* WebLN */}
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<Globe className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">WebLN</p>
<p className="text-xs text-muted-foreground">Browser extension</p>
</div>
</div>
<div className="flex items-center gap-2">
{hasWebLN && <CheckCircle className="h-4 w-4 text-green-600" />}
<Badge variant={hasWebLN ? "default" : "secondary"} className="text-xs">
{isDetecting ? "..." : hasWebLN ? "Ready" : "Not Found"}
</Badge>
</div>
</div>
{/* NWC */}
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<WalletMinimal className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Nostr Wallet Connect</p>
<p className="text-xs text-muted-foreground">
{connections.length > 0
? `${connections.length} wallet${connections.length !== 1 ? 's' : ''} connected`
: "Remote wallet connection"
}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{hasNWC && <CheckCircle className="h-4 w-4 text-green-600" />}
<Badge variant={hasNWC ? "default" : "secondary"} className="text-xs">
{hasNWC ? "Ready" : "None"}
</Badge>
</div>
</div>
</div>
</div>
<Separator />
{/* NWC Management */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">Nostr Wallet Connect</h3>
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{/* Connected Wallets List */}
{connections.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<p className="text-sm">No wallets connected</p>
</div>
) : (
<div className="space-y-2">
{connections.map((connection) => {
const info = connectionInfo[connection.connectionString];
const isActive = activeConnection === connection.connectionString;
return (
<div key={connection.connectionString} className={`flex items-center justify-between p-3 border rounded-lg ${isActive ? 'ring-2 ring-primary' : ''}`}>
<div className="flex items-center gap-3">
<WalletMinimal className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{connection.alias || info?.alias || 'Lightning Wallet'}
</p>
<p className="text-xs text-muted-foreground">
NWC Connection
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isActive && <CheckCircle className="h-4 w-4 text-green-600" />}
{!isActive && (
<Button
size="sm"
variant="ghost"
onClick={() => handleSetActive(connection.connectionString)}
>
<Zap className="h-3 w-3" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveConnection(connection.connectionString)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Help */}
{!hasWebLN && connections.length === 0 && (
<>
<Separator />
<div className="text-center py-4 space-y-2">
<p className="text-sm text-muted-foreground">
Install a WebLN extension or connect a NWC wallet for zaps.
</p>
</div>
</>
)}
</div>
));
WalletContent.displayName = 'WalletContent';
export function WalletModal({ children, className }: WalletModalProps) {
const [open, setOpen] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [connectionUri, setConnectionUri] = useState('');
const [alias, setAlias] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const isMobile = useIsMobile();
const {
connections,
activeConnection,
connectionInfo,
addConnection,
removeConnection,
setActiveConnection
} = useNWC();
const { hasWebLN, isDetecting } = useWallet();
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
const { toast } = useToast();
const handleAddConnection = async () => {
if (!connectionUri.trim()) {
toast({
title: 'Connection URI required',
description: 'Please enter a valid NWC connection URI.',
variant: 'destructive',
});
return;
}
setIsConnecting(true);
try {
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
if (success) {
setConnectionUri('');
setAlias('');
setAddDialogOpen(false);
}
} finally {
setIsConnecting(false);
}
};
const handleRemoveConnection = (connectionString: string) => {
removeConnection(connectionString);
};
const handleSetActive = (connectionString: string) => {
setActiveConnection(connectionString);
toast({
title: 'Active wallet changed',
description: 'The selected wallet is now active for zaps.',
});
};
const walletContentProps = {
hasWebLN,
isDetecting,
hasNWC,
connections,
connectionInfo,
activeConnection,
handleSetActive,
handleRemoveConnection,
setAddDialogOpen,
};
const addWalletDialog = (
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Connect NWC Wallet</DialogTitle>
<DialogDescription>
Enter your connection string from a compatible wallet.
</DialogDescription>
</DialogHeader>
<AddWalletContent
alias={alias}
setAlias={setAlias}
connectionUri={connectionUri}
setConnectionUri={setConnectionUri}
/>
<DialogFooter className="px-4">
<Button
onClick={handleAddConnection}
disabled={isConnecting || !connectionUri.trim()}
className="w-full"
>
{isConnecting ? 'Connecting...' : 'Connect'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
if (isMobile) {
return (
<>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
{children || (
<Button variant="outline" size="sm" className={className}>
<Wallet className="h-4 w-4 mr-2" />
Wallet Settings
</Button>
)}
</DrawerTrigger>
<DrawerContent className="h-full">
<DrawerHeader className="text-center relative">
<DrawerClose asChild>
<Button variant="ghost" size="sm" className="absolute right-4 top-4">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</DrawerClose>
<DrawerTitle className="flex items-center justify-center gap-2 pt-2">
<Wallet className="h-5 w-5" />
Lightning Wallet
</DrawerTitle>
<DrawerDescription>
Connect your lightning wallet to send zaps instantly.
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto">
<WalletContent {...walletContentProps} />
</div>
</DrawerContent>
</Drawer>
{/* Render Add Wallet as a separate Drawer for mobile */}
<Drawer open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Connect NWC Wallet</DrawerTitle>
<DrawerDescription>
Enter your connection string from a compatible wallet.
</DrawerDescription>
</DrawerHeader>
<AddWalletContent
alias={alias}
setAlias={setAlias}
connectionUri={connectionUri}
setConnectionUri={setConnectionUri}
/>
<div className="p-4">
<Button
onClick={handleAddConnection}
disabled={isConnecting || !connectionUri.trim()}
className="w-full"
>
{isConnecting ? 'Connecting...' : 'Connect'}
</Button>
</div>
</DrawerContent>
</Drawer>
</>
);
}
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button variant="outline" size="sm" className={className}>
<Wallet className="h-4 w-4 mr-2" />
Wallet Settings
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wallet className="h-5 w-5" />
Lightning Wallet
</DialogTitle>
<DialogDescription>
Connect your lightning wallet to send zaps instantly.
</DialogDescription>
</DialogHeader>
<WalletContent {...walletContentProps} />
</DialogContent>
</Dialog>
{addWalletDialog}
</>
);
}

View File

@ -0,0 +1,58 @@
import { ZapDialog } from '@/components/ZapDialog';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { Zap } from 'lucide-react';
import type { Event } from 'nostr-tools';
interface ZapButtonProps {
target: Event;
className?: string;
showCount?: boolean;
zapData?: { count: number; totalSats: number; isLoading?: boolean };
}
export function ZapButton({
target,
className = "text-xs ml-1",
showCount = true,
zapData: externalZapData
}: ZapButtonProps) {
const { user } = useCurrentUser();
const { data: author } = useAuthor(target?.pubkey || '');
const { webln, activeNWC } = useWallet();
// Only fetch data if not provided externally
const { totalSats: fetchedTotalSats, isLoading } = useZaps(
externalZapData ? [] : target ?? [], // Empty array prevents fetching if external data provided
webln,
activeNWC
);
// Don't show zap button if user is not logged in, is the author, or author has no lightning address
if (!user || !target || user.pubkey === target.pubkey || (!author?.metadata?.lud16 && !author?.metadata?.lud06)) {
return null;
}
// Use external data if provided, otherwise use fetched data
const totalSats = externalZapData?.totalSats ?? fetchedTotalSats;
const showLoading = externalZapData?.isLoading || isLoading;
return (
<ZapDialog target={target}>
<div className={`flex items-center gap-1 ${className}`}>
<Zap className="h-4 w-4" />
<span className="text-xs">
{showLoading ? (
'...'
) : showCount && totalSats > 0 ? (
`${totalSats.toLocaleString()}`
) : (
'Zap'
)}
</span>
</div>
</ZapDialog>
);
}

View File

@ -0,0 +1,470 @@
import { useState, useEffect, useRef, forwardRef } from 'react';
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
DrawerClose,
} from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useIsMobile } from '@/hooks/useIsMobile';
import type { Event } from 'nostr-tools';
import QRCode from 'qrcode';
interface ZapDialogProps {
target: Event;
children?: React.ReactNode;
className?: string;
}
const presetAmounts = [
{ amount: 1, icon: Sparkle },
{ amount: 50, icon: Sparkles },
{ amount: 100, icon: Zap },
{ amount: 250, icon: Star },
{ amount: 1000, icon: Rocket },
];
interface ZapContentProps {
invoice: string | null;
amount: number | string;
comment: string;
isZapping: boolean;
qrCodeUrl: string;
copied: boolean;
hasWebLN: boolean;
handleZap: () => void;
handleCopy: () => void;
openInWallet: () => void;
setAmount: (amount: number | string) => void;
setComment: (comment: string) => void;
inputRef: React.RefObject<HTMLInputElement>;
zap: (amount: number, comment: string) => void;
}
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
invoice,
amount,
comment,
isZapping,
qrCodeUrl,
copied,
hasWebLN,
handleZap,
handleCopy,
openInWallet,
setAmount,
setComment,
inputRef,
zap,
}, ref) => (
<div ref={ref}>
{invoice ? (
<div className="flex flex-col h-full min-h-0">
{/* Payment amount display */}
<div className="text-center pt-4">
<div className="text-2xl font-bold">{amount} sats</div>
</div>
<Separator className="my-4" />
<div className="flex flex-col justify-center min-h-0 flex-1 px-2">
{/* QR Code */}
<div className="flex justify-center">
<Card className="p-3 [@media(max-height:680px)]:max-w-[65vw] max-w-[95vw] mx-auto">
<CardContent className="p-0 flex justify-center">
{qrCodeUrl ? (
<img
src={qrCodeUrl}
alt="Lightning Invoice QR Code"
className="w-full h-auto aspect-square max-w-full object-contain"
/>
) : (
<div className="w-full aspect-square bg-muted animate-pulse rounded" />
)}
</CardContent>
</Card>
</div>
{/* Invoice input */}
<div className="space-y-2 mt-4">
<Label htmlFor="invoice">Lightning Invoice</Label>
<div className="flex gap-2 min-w-0">
<Input
id="invoice"
value={invoice}
readOnly
className="font-mono text-xs min-w-0 flex-1 overflow-hidden text-ellipsis"
onClick={(e) => e.currentTarget.select()}
/>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Payment buttons */}
<div className="space-y-3 mt-4">
{hasWebLN && (
<Button
onClick={() => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
}}
disabled={isZapping}
className="w-full"
size="lg"
>
<Zap className="h-4 w-4 mr-2" />
{isZapping ? "Processing..." : "Pay with WebLN"}
</Button>
)}
<Button
variant="outline"
onClick={openInWallet}
className="w-full"
size="lg"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open in Lightning Wallet
</Button>
<div className="text-xs sm:text-[.65rem] text-muted-foreground text-center">
Scan the QR code or copy the invoice to pay with any Lightning wallet.
</div>
</div>
</div>
</div>
) : (
<>
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
<ToggleGroup
type="single"
value={String(amount)}
onValueChange={(value) => {
if (value) {
setAmount(parseInt(value, 10));
}
}}
className="grid grid-cols-5 gap-1 w-full"
>
{presetAmounts.map(({ amount: presetAmount, icon: Icon }) => (
<ToggleGroupItem
key={presetAmount}
value={String(presetAmount)}
className="flex flex-col h-auto min-w-0 text-xs px-1 py-2"
>
<Icon className="h-4 w-4 mb-1" />
<span className="truncate">{presetAmount}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-muted" />
<span className="text-xs text-muted-foreground">OR</span>
<div className="h-px flex-1 bg-muted" />
</div>
<Input
ref={inputRef}
id="custom-amount"
type="number"
placeholder="Custom amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full text-sm"
/>
<Textarea
id="custom-comment"
placeholder="Add a comment (optional)"
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full resize-none text-sm"
rows={2}
/>
</div>
<div className="px-4 pb-4">
<Button onClick={handleZap} className="w-full" disabled={isZapping} size="default">
{isZapping ? (
'Creating invoice...'
) : (
<>
<Zap className="h-4 w-4 mr-2" />
Zap {amount} sats
</>
)}
</Button>
</div>
</>
)}
</div>
));
ZapContent.displayName = 'ZapContent';
export function ZapDialog({ target, children, className }: ZapDialogProps) {
const [open, setOpen] = useState(false);
const { user } = useCurrentUser();
const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast();
const { webln, activeNWC, hasWebLN, detectWebLN } = useWallet();
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
const [amount, setAmount] = useState<number | string>(100);
const [comment, setComment] = useState<string>('');
const [copied, setCopied] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const inputRef = useRef<HTMLInputElement>(null);
const isMobile = useIsMobile();
useEffect(() => {
if (target) {
setComment('Zapped with MKStack!');
}
}, [target]);
// Detect WebLN when dialog opens
useEffect(() => {
if (open && !hasWebLN) {
detectWebLN();
}
}, [open, hasWebLN, detectWebLN]);
// Generate QR code
useEffect(() => {
let isCancelled = false;
const generateQR = async () => {
if (!invoice) {
setQrCodeUrl('');
return;
}
try {
const url = await QRCode.toDataURL(invoice.toUpperCase(), {
width: 512,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
if (!isCancelled) {
setQrCodeUrl(url);
}
} catch (err) {
if (!isCancelled) {
console.error('Failed to generate QR code:', err);
}
}
};
generateQR();
return () => {
isCancelled = true;
};
}, [invoice]);
const handleCopy = async () => {
if (invoice) {
await navigator.clipboard.writeText(invoice);
setCopied(true);
toast({
title: 'Invoice copied',
description: 'Lightning invoice copied to clipboard',
});
setTimeout(() => setCopied(false), 2000);
}
};
const openInWallet = () => {
if (invoice) {
const lightningUrl = `lightning:${invoice}`;
window.open(lightningUrl, '_blank');
}
};
useEffect(() => {
if (open) {
setAmount(100);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
} else {
// Clean up state when dialog closes
setAmount(100);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
}
}, [open, setInvoice]);
const handleZap = () => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
};
const contentProps = {
invoice,
amount,
comment,
isZapping,
qrCodeUrl,
copied,
hasWebLN,
handleZap,
handleCopy,
openInWallet,
setAmount,
setComment,
inputRef,
zap,
};
if (!user || user.pubkey === target.pubkey || !author?.metadata?.lud06 && !author?.metadata?.lud16) {
return null;
}
if (isMobile) {
// Use drawer for entire mobile flow, make it full-screen when showing invoice
return (
<Drawer
open={open}
onOpenChange={(newOpen) => {
// Reset invoice when closing
if (!newOpen) {
setInvoice(null);
setQrCodeUrl('');
}
setOpen(newOpen);
}}
dismissible={true} // Always allow dismissal via drag
snapPoints={invoice ? [0.5, 0.75, 0.98] : [0.98]}
activeSnapPoint={invoice ? 0.98 : 0.98}
modal={true}
shouldScaleBackground={false}
fadeFromIndex={0}
>
<DrawerTrigger asChild>
<div className={`cursor-pointer ${className || ''}`}>
{children}
</div>
</DrawerTrigger>
<DrawerContent
key={invoice ? 'payment' : 'form'}
className={cn(
"transition-all duration-300",
invoice ? "h-full max-h-screen" : "max-h-[98vh]"
)}
data-testid="zap-modal"
>
<DrawerHeader className="text-center relative">
{/* Back button when showing invoice */}
{invoice && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setInvoice(null);
setQrCodeUrl('');
}}
className="absolute left-4 top-4 flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{/* Close button */}
<DrawerClose asChild>
<Button
variant="ghost"
size="sm"
className="absolute right-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</DrawerClose>
<DrawerTitle className="text-lg break-words pt-2">
{invoice ? 'Lightning Payment' : 'Send a Zap'}
</DrawerTitle>
<DrawerDescription className="text-sm break-words text-center">
{invoice ? (
'Pay with Bitcoin Lightning Network'
) : (
'Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!'
)}
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<ZapContent {...contentProps} />
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<div className={`cursor-pointer ${className || ''}`}>
{children}
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] max-h-[95vh] overflow-hidden" data-testid="zap-modal">
<DialogHeader>
<DialogTitle className="text-lg break-words">
{invoice ? 'Lightning Payment' : 'Send a Zap'}
</DialogTitle>
<DialogDescription className="text-sm text-center break-words">
{invoice ? (
'Pay with Bitcoin Lightning Network'
) : (
<>
Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!
</>
)}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto">
<ZapContent {...contentProps} />
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,7 +1,7 @@
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import { ChevronDown, LogOut, UserIcon, UserPlus } from 'lucide-react';
import { ChevronDown, LogOut, UserIcon, UserPlus, Wallet } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@ -11,6 +11,7 @@ import {
} from '@/components/ui/dropdown-menu.tsx';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
import { RelaySelector } from '@/components/RelaySelector';
import { WalletModal } from '@/components/WalletModal';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { genUserName } from '@/lib/genUserName';
@ -63,6 +64,15 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<WalletModal>
<DropdownMenuItem
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
onSelect={(e) => e.preventDefault()}
>
<Wallet className='w-4 h-4' />
<span>Wallet Settings</span>
</DropdownMenuItem>
</WalletModal>
<DropdownMenuItem
onClick={onAddAccountClick}
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'

View File

@ -0,0 +1,8 @@
import { ReactNode } from 'react';
import { useNWCInternal as useNWCHook } from '@/hooks/useNWC';
import { NWCContext } from '@/hooks/useNWCContext';
export function NWCProvider({ children }: { children: ReactNode }) {
const nwc = useNWCHook();
return <NWCContext.Provider value={nwc}>{children}</NWCContext.Provider>;
}

221
src/hooks/useNWC.ts Normal file
View File

@ -0,0 +1,221 @@
import { useState, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useToast } from '@/hooks/useToast';
import { LN } from '@getalby/sdk';
export interface NWCConnection {
connectionString: string;
alias?: string;
isConnected: boolean;
client?: LN;
}
export interface NWCInfo {
alias?: string;
color?: string;
pubkey?: string;
network?: string;
methods?: string[];
notifications?: string[];
}
export function useNWCInternal() {
const { toast } = useToast();
const [connections, setConnections] = useLocalStorage<NWCConnection[]>('nwc-connections', []);
const [activeConnection, setActiveConnection] = useLocalStorage<string | null>('nwc-active-connection', null);
const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({});
// Add new connection
const addConnection = async (uri: string, alias?: string): Promise<boolean> => {
const parseNWCUri = (uri: string): { connectionString: string } | null => {
try {
if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) {
console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] });
return null;
}
return { connectionString: uri };
} catch (error) {
console.error('Failed to parse NWC URI:', error);
return null;
}
};
const parsed = parseNWCUri(uri);
if (!parsed) {
toast({
title: 'Invalid NWC URI',
description: 'Please check the connection string and try again.',
variant: 'destructive',
});
return false;
}
const existingConnection = connections.find(c => c.connectionString === parsed.connectionString);
if (existingConnection) {
toast({
title: 'Connection already exists',
description: 'This wallet is already connected.',
variant: 'destructive',
});
return false;
}
try {
let timeoutId: NodeJS.Timeout | undefined;
const testPromise = new Promise((resolve, reject) => {
try {
const client = new LN(parsed.connectionString);
resolve(client);
} catch (error) {
reject(error);
}
});
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Connection test timeout')), 10000);
});
try {
await Promise.race([testPromise, timeoutPromise]) as LN;
if (timeoutId) clearTimeout(timeoutId);
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
throw error;
}
const connection: NWCConnection = {
connectionString: parsed.connectionString,
alias: alias || 'NWC Wallet',
isConnected: true,
};
setConnectionInfo(prev => ({
...prev,
[parsed.connectionString]: {
alias: connection.alias,
methods: ['pay_invoice'],
},
}));
const newConnections = [...connections, connection];
setConnections(newConnections);
if (connections.length === 0 || !activeConnection)
setActiveConnection(parsed.connectionString);
toast({
title: 'Wallet connected',
description: `Successfully connected to ${connection.alias}.`,
});
return true;
} catch (error) {
console.error('NWC connection failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
toast({
title: 'Connection failed',
description: `Could not connect to the wallet: ${errorMessage}`,
variant: 'destructive',
});
return false;
}
};
// Remove connection
const removeConnection = (connectionString: string) => {
const filtered = connections.filter(c => c.connectionString !== connectionString);
setConnections(filtered);
if (activeConnection === connectionString) {
const newActive = filtered.length > 0 ? filtered[0].connectionString : null;
setActiveConnection(newActive);
}
setConnectionInfo(prev => {
const newInfo = { ...prev };
delete newInfo[connectionString];
return newInfo;
});
toast({
title: 'Wallet disconnected',
description: 'The wallet connection has been removed.',
});
};
// Get active connection
const getActiveConnection = useCallback((): NWCConnection | null => {
if (!activeConnection && connections.length > 0) {
setActiveConnection(connections[0].connectionString);
return connections[0];
}
if (!activeConnection) return null;
const found = connections.find(c => c.connectionString === activeConnection);
return found || null;
}, [activeConnection, connections, setActiveConnection]);
// Send payment using the SDK
const sendPayment = useCallback(async (
connection: NWCConnection,
invoice: string
): Promise<{ preimage: string }> => {
if (!connection.connectionString) {
throw new Error('Invalid connection: missing connection string');
}
let client: LN;
try {
client = new LN(connection.connectionString);
} catch (error) {
console.error('Failed to create NWC client:', error);
throw new Error(`Failed to create NWC client: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
try {
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Payment timeout after 15 seconds')), 15000);
});
const paymentPromise = client.pay(invoice);
try {
const response = await Promise.race([paymentPromise, timeoutPromise]) as { preimage: string };
if (timeoutId) clearTimeout(timeoutId);
return response;
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
throw error;
}
} catch (error) {
console.error('NWC payment failed:', error);
if (error instanceof Error) {
if (error.message.includes('timeout')) {
throw new Error('Payment timed out. Please try again.');
} else if (error.message.includes('insufficient')) {
throw new Error('Insufficient balance in connected wallet.');
} else if (error.message.includes('invalid')) {
throw new Error('Invalid invoice or connection. Please check your wallet.');
} else {
throw new Error(`Payment failed: ${error.message}`);
}
}
throw new Error('Payment failed with unknown error');
}
}, []);
return {
connections,
activeConnection,
connectionInfo,
addConnection,
removeConnection,
setActiveConnection,
getActiveConnection,
sendPayment,
};
}

View File

@ -0,0 +1,15 @@
import { useContext } from 'react';
import { createContext } from 'react';
import { useNWCInternal } from '@/hooks/useNWC';
type NWCContextType = ReturnType<typeof useNWCInternal>;
export const NWCContext = createContext<NWCContextType | null>(null);
export function useNWC(): NWCContextType {
const context = useContext(NWCContext);
if (!context) {
throw new Error('useNWC must be used within a NWCProvider');
}
return context;
}

94
src/hooks/useWallet.ts Normal file
View File

@ -0,0 +1,94 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNWC } from '@/hooks/useNWCContext';
import type { WebLNProvider } from 'webln';
import { requestProvider } from 'webln';
export interface WalletStatus {
hasWebLN: boolean;
hasNWC: boolean;
webln: WebLNProvider | null;
activeNWC: ReturnType<typeof useNWC>['getActiveConnection'] extends () => infer T ? T : null;
isDetecting: boolean;
preferredMethod: 'nwc' | 'webln' | 'manual';
}
export function useWallet() {
const [webln, setWebln] = useState<WebLNProvider | null>(null);
const [isDetecting, setIsDetecting] = useState(false);
const [hasAttemptedDetection, setHasAttemptedDetection] = useState(false);
const { connections, getActiveConnection } = useNWC();
// Get the active connection directly - no memoization to avoid stale state
const activeNWC = getActiveConnection();
// Detect WebLN
const detectWebLN = useCallback(async () => {
if (webln || isDetecting) return webln;
setIsDetecting(true);
try {
const provider = await requestProvider();
setWebln(provider);
setHasAttemptedDetection(true);
return provider;
} catch (error) {
// Only log the error if it's not the common "no provider" error
if (error instanceof Error && !error.message.includes('no WebLN provider')) {
console.warn('WebLN detection error:', error);
}
setWebln(null);
setHasAttemptedDetection(true);
return null;
} finally {
setIsDetecting(false);
}
}, [webln, isDetecting]);
// Only auto-detect once on mount
useEffect(() => {
if (!hasAttemptedDetection) {
detectWebLN();
}
}, [detectWebLN, hasAttemptedDetection]);
// Test WebLN connection
const testWebLN = useCallback(async (): Promise<boolean> => {
if (!webln) return false;
try {
await webln.enable();
return true;
} catch (error) {
console.error('WebLN test failed:', error);
return false;
}
}, [webln]);
// Calculate status values reactively
const hasNWC = useMemo(() => {
return connections.length > 0 && connections.some(c => c.isConnected);
}, [connections]);
// Determine preferred payment method
const preferredMethod: WalletStatus['preferredMethod'] = activeNWC
? 'nwc'
: webln
? 'webln'
: 'manual';
const status: WalletStatus = {
hasWebLN: !!webln,
hasNWC,
webln,
activeNWC,
isDetecting,
preferredMethod,
};
return {
...status,
hasAttemptedDetection,
detectWebLN,
testWebLN,
};
}

338
src/hooks/useZaps.ts Normal file
View File

@ -0,0 +1,338 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useNWC } from '@/hooks/useNWCContext';
import type { NWCConnection } from '@/hooks/useNWC';
import { nip57 } from 'nostr-tools';
import type { Event } from 'nostr-tools';
import type { WebLNProvider } from 'webln';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
export function useZaps(
target: Event | Event[],
webln: WebLNProvider | null,
_nwcConnection: NWCConnection | null,
onZapSuccess?: () => void
) {
const { nostr } = useNostr();
const { toast } = useToast();
const { user } = useCurrentUser();
const { config } = useAppContext();
const queryClient = useQueryClient();
// Handle the case where an empty array is passed (from ZapButton when external data is provided)
const actualTarget = Array.isArray(target) ? (target.length > 0 ? target[0] : null) : target;
const author = useAuthor(actualTarget?.pubkey);
const { sendPayment, getActiveConnection } = useNWC();
const [isZapping, setIsZapping] = useState(false);
const [invoice, setInvoice] = useState<string | null>(null);
// Cleanup state when component unmounts
useEffect(() => {
return () => {
setIsZapping(false);
setInvoice(null);
};
}, []);
const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({
queryKey: ['zaps', actualTarget?.id],
staleTime: 30000, // 30 seconds
refetchInterval: (query) => {
// Only refetch if the query is currently being observed (component is mounted)
return query.getObserversCount() > 0 ? 60000 : false;
},
queryFn: async (c) => {
if (!actualTarget) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
// Query for zap receipts for this specific event
if (actualTarget.kind >= 30000 && actualTarget.kind < 40000) {
// Addressable event
const identifier = actualTarget.tags.find((t) => t[0] === 'd')?.[1] || '';
const events = await nostr.query([{
kinds: [9735],
'#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`],
}], { signal });
return events;
} else {
// Regular event
const events = await nostr.query([{
kinds: [9735],
'#e': [actualTarget.id],
}], { signal });
return events;
}
},
enabled: !!actualTarget?.id,
});
// Process zap events into simple counts and totals
const { zapCount, totalSats, zaps } = useMemo(() => {
if (!zapEvents || !Array.isArray(zapEvents) || !actualTarget) {
return { zapCount: 0, totalSats: 0, zaps: [] };
}
let count = 0;
let sats = 0;
zapEvents.forEach(zap => {
count++;
// Try multiple methods to extract the amount:
// Method 1: amount tag (from zap request, sometimes copied to receipt)
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
if (amountTag) {
const millisats = parseInt(amountTag);
sats += Math.floor(millisats / 1000);
return;
}
// Method 2: Extract from bolt11 invoice
const bolt11Tag = zap.tags.find(([name]) => name === 'bolt11')?.[1];
if (bolt11Tag) {
try {
const invoiceSats = nip57.getSatoshisAmountFromBolt11(bolt11Tag);
sats += invoiceSats;
return;
} catch (error) {
console.warn('Failed to parse bolt11 amount:', error);
}
}
// Method 3: Parse from description (zap request JSON)
const descriptionTag = zap.tags.find(([name]) => name === 'description')?.[1];
if (descriptionTag) {
try {
const zapRequest = JSON.parse(descriptionTag);
const requestAmountTag = zapRequest.tags?.find(([name]: string[]) => name === 'amount')?.[1];
if (requestAmountTag) {
const millisats = parseInt(requestAmountTag);
sats += Math.floor(millisats / 1000);
return;
}
} catch (error) {
console.warn('Failed to parse description JSON:', error);
}
}
console.warn('Could not extract amount from zap receipt:', zap.id);
});
return { zapCount: count, totalSats: sats, zaps: zapEvents };
}, [zapEvents, actualTarget]);
const zap = async (amount: number, comment: string) => {
if (amount <= 0) {
return;
}
setIsZapping(true);
setInvoice(null); // Clear any previous invoice at the start
if (!user) {
toast({
title: 'Login required',
description: 'You must be logged in to send a zap.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
if (!actualTarget) {
toast({
title: 'Event not found',
description: 'Could not find the event to zap.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
try {
if (!author.data || !author.data?.metadata || !author.data?.event ) {
toast({
title: 'Author not found',
description: 'Could not find the author of this item.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
const { lud06, lud16 } = author.data.metadata;
if (!lud06 && !lud16) {
toast({
title: 'Lightning address not found',
description: 'The author does not have a lightning address configured.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
// Get zap endpoint using the old reliable method
const zapEndpoint = await nip57.getZapEndpoint(author.data.event);
if (!zapEndpoint) {
toast({
title: 'Zap endpoint not found',
description: 'Could not find a zap endpoint for the author.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
// Create zap request - use appropriate event format based on kind
// For addressable events (30000-39999), pass the object to get 'a' tag
// For all other events, pass the ID string to get 'e' tag
const event = (actualTarget.kind >= 30000 && actualTarget.kind < 40000)
? actualTarget
: actualTarget.id;
const zapAmount = amount * 1000; // convert to millisats
const zapRequest = nip57.makeZapRequest({
profile: actualTarget.pubkey,
event: event,
amount: zapAmount,
relays: [config.relayUrl],
comment
});
// Sign the zap request (but don't publish to relays - only send to LNURL endpoint)
if (!user.signer) {
throw new Error('No signer available');
}
const signedZapRequest = await user.signer.signEvent(zapRequest);
try {
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(signedZapRequest))}`);
const responseData = await res.json();
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${responseData.reason || 'Unknown error'}`);
}
const newInvoice = responseData.pr;
if (!newInvoice || typeof newInvoice !== 'string') {
throw new Error('Lightning service did not return a valid invoice');
}
// Get the current active NWC connection dynamically
const currentNWCConnection = getActiveConnection();
// Try NWC first if available and properly connected
if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) {
try {
await sendPayment(currentNWCConnection, newInvoice);
// Clear states immediately on success
setIsZapping(false);
setInvoice(null);
toast({
title: 'Zap successful!',
description: `You sent ${amount} sats via NWC to the author.`,
});
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
// Close dialog last to ensure clean state
onZapSuccess?.();
return;
} catch (nwcError) {
console.error('NWC payment failed, falling back:', nwcError);
// Show specific NWC error to user for debugging
const errorMessage = nwcError instanceof Error ? nwcError.message : 'Unknown NWC error';
toast({
title: 'NWC payment failed',
description: `${errorMessage}. Falling back to other payment methods...`,
variant: 'destructive',
});
}
}
if (webln) { // Try WebLN next
try {
await webln.sendPayment(newInvoice);
// Clear states immediately on success
setIsZapping(false);
setInvoice(null);
toast({
title: 'Zap successful!',
description: `You sent ${amount} sats to the author.`,
});
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
// Close dialog last to ensure clean state
onZapSuccess?.();
} catch (weblnError) {
console.error('webln payment failed, falling back:', weblnError);
// Show specific WebLN error to user for debugging
const errorMessage = weblnError instanceof Error ? weblnError.message : 'Unknown WebLN error';
toast({
title: 'WebLN payment failed',
description: `${errorMessage}. Falling back to other payment methods...`,
variant: 'destructive',
});
setInvoice(newInvoice);
setIsZapping(false);
}
} else { // Default - show QR code and manual Lightning URI
setInvoice(newInvoice);
setIsZapping(false);
}
} catch (err) {
console.error('Zap error:', err);
toast({
title: 'Zap failed',
description: (err as Error).message,
variant: 'destructive',
});
setIsZapping(false);
}
} catch (err) {
console.error('Zap error:', err);
toast({
title: 'Zap failed',
description: (err as Error).message,
variant: 'destructive',
});
setIsZapping(false);
}
};
const resetInvoice = useCallback(() => {
setInvoice(null);
}, []);
return {
zaps,
zapCount,
totalSats,
...query,
zap,
isZapping,
invoice,
setInvoice,
resetInvoice,
};
}

View File

@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
import { NostrLoginProvider } from '@nostrify/react/login';
import NostrProvider from '@/components/NostrProvider';
import { AppProvider } from '@/components/AppProvider';
import { NWCProvider } from '@/contexts/NWCContext';
import { AppConfig } from '@/contexts/AppContext';
interface TestAppProps {
@ -31,9 +32,11 @@ export function TestApp({ children }: TestAppProps) {
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='test-login'>
<NostrProvider>
<BrowserRouter>
{children}
</BrowserRouter>
<NWCProvider>
<BrowserRouter>
{children}
</BrowserRouter>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>