mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-26 20:49:22 +00:00
Merge branch 'zapSupport' into 'main'
Implement Zap + NWC Wallet support See merge request soapbox-pub/mkstack!11
This commit is contained in:
commit
7473c794cc
@ -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
396
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
17
src/App.tsx
17
src/App.tsx
@ -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>
|
||||
|
395
src/components/WalletModal.tsx
Normal file
395
src/components/WalletModal.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
58
src/components/ZapButton.tsx
Normal file
58
src/components/ZapButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
470
src/components/ZapDialog.tsx
Normal file
470
src/components/ZapDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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'
|
||||
|
8
src/contexts/NWCContext.tsx
Normal file
8
src/contexts/NWCContext.tsx
Normal 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
221
src/hooks/useNWC.ts
Normal 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,
|
||||
};
|
||||
}
|
15
src/hooks/useNWCContext.ts
Normal file
15
src/hooks/useNWCContext.ts
Normal 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
94
src/hooks/useWallet.ts
Normal 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
338
src/hooks/useZaps.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user