diff --git a/package-lock.json b/package-lock.json index 6ef4afb..05e8e20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,8 @@ "applesauce-react": "^5.0.1", "applesauce-relay": "^5.0.0", "applesauce-signers": "^5.0.0", + "applesauce-wallet": "^5.0.0", + "applesauce-wallet-connect": "^5.0.1", "blossom-client-sdk": "^4.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -127,6 +129,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@apocentre/alias-sampling": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@apocentre/alias-sampling/-/alias-sampling-0.5.3.tgz", + "integrity": "sha512-7UDWIIF9hIeJqfKXkNIzkVandlwLf1FWTSdrb9iXvOP8oF544JRXQjCbiTmCv2c9n44n/FIWtehhBfNuAx2CZA==", + "license": "GPL" + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", @@ -1502,6 +1510,21 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@gandlaf21/bc-ur": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@gandlaf21/bc-ur/-/bc-ur-1.1.12.tgz", + "integrity": "sha512-AQfbZJ1o1AdK9/W9VcTyMkwp6iZWDWQQV2SGep2ygJUkTNaafSjdWLUgpc6Uo/VLlGYaS9A28gCh+GVtAdwTpA==", + "license": "MIT", + "dependencies": { + "@apocentre/alias-sampling": "^0.5.3", + "@noble/hashes": "^1.3.3", + "bignumber.js": "^9.0.1", + "buffer": "^6.0.3", + "cbor-sync": "^1.0.4", + "cborg": "^4.0.9", + "jsbi": "3.1.5" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -5707,6 +5730,40 @@ "url": "lightning:nostrudel@geyser.fund" } }, + "node_modules/applesauce-wallet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/applesauce-wallet/-/applesauce-wallet-5.0.0.tgz", + "integrity": "sha512-hPn3tXQEhxzI+ar8Lxp3D27QFjuk2+sf8tbj6m0G8aks4HTj0Pl+ucoUfU7e7DCgvr0yDv0ObWQSxX8KHGixaA==", + "license": "MIT", + "dependencies": { + "@cashu/cashu-ts": "^3.1.1", + "@gandlaf21/bc-ur": "^1.1.12", + "applesauce-actions": "^5.0.0", + "applesauce-common": "^5.0.0", + "applesauce-core": "^5.0.0", + "rxjs": "^7.8.1" + }, + "funding": { + "type": "lightning", + "url": "lightning:nostrudel@geyser.fund" + } + }, + "node_modules/applesauce-wallet-connect": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/applesauce-wallet-connect/-/applesauce-wallet-connect-5.0.1.tgz", + "integrity": "sha512-k/Gl2IIjfQelW4deN/0M9/I3uznUMZalGAP9/wPgwmAtUyaEHb8YJpOdxqLwCQ98vZMTAcwgK6hmXkAPqA6NTg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.7.1", + "applesauce-common": "^5.0.0", + "applesauce-core": "^5.0.0", + "rxjs": "^7.8.1" + }, + "funding": { + "type": "lightning", + "url": "lightning:nostrudel@geyser.fund" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -5820,6 +5877,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.8.31", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", @@ -5840,6 +5917,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5967,6 +6053,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -6027,6 +6137,21 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cbor-sync": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cbor-sync/-/cbor-sync-1.0.4.tgz", + "integrity": "sha512-GWlXN4wiz0vdWWXBU71Dvc1q3aBo0HytqwAZnXF1wOwjqNnDWA1vZ1gDMFLlqohak31VQzmhiYfiCX5QSSfagA==", + "license": "MIT" + }, + "node_modules/cborg": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.3.2.tgz", + "integrity": "sha512-l+QzebEAG0vb09YKkaOrMi2zmm80UNjmbvocMIeW5hO7JOXWdrQ/H49yOKfYX0MBgrj/KWgatBnEgRXyNyKD+A==", + "license": "Apache-2.0", + "bin": { + "cborg": "lib/bin.js" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -7561,6 +7686,26 @@ "node": ">= 14" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7834,6 +7979,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-3.1.5.tgz", + "integrity": "sha512-w2BY0VOYC1ahe+w6Qhl4SFoPvPsZ9NPHY4bwass+LCgU7RK3PBoVQlQ3G1s7vI8W3CYyJiEXcbKF7FIM/L8q3Q==", + "license": "Apache-2.0" + }, "node_modules/jsdom": { "version": "27.4.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", diff --git a/package.json b/package.json index 4a13e62..160f261 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "applesauce-react": "^5.0.1", "applesauce-relay": "^5.0.0", "applesauce-signers": "^5.0.0", + "applesauce-wallet": "^5.0.0", + "applesauce-wallet-connect": "^5.0.1", "blossom-client-sdk": "^4.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/ConnectWalletDialog.tsx b/src/components/ConnectWalletDialog.tsx new file mode 100644 index 0000000..7f05fb5 --- /dev/null +++ b/src/components/ConnectWalletDialog.tsx @@ -0,0 +1,176 @@ +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { Loader2, Wallet, AlertCircle } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useGrimoire } from "@/core/state"; +import { createWalletFromURI } from "@/services/nwc"; + +interface ConnectWalletDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function ConnectWalletDialog({ + open, + onOpenChange, +}: ConnectWalletDialogProps) { + const [connectionString, setConnectionString] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { setNWCConnection, updateNWCBalance, updateNWCInfo } = useGrimoire(); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setConnectionString(""); + setLoading(false); + setError(null); + } + }, [open]); + + async function handleConnect() { + if (!connectionString.trim()) { + setError("Please enter a connection string"); + return; + } + + if (!connectionString.startsWith("nostr+walletconnect://")) { + setError( + "Invalid connection string. Must start with nostr+walletconnect://", + ); + return; + } + + setLoading(true); + setError(null); + + try { + // Create wallet instance from connection string + const wallet = createWalletFromURI(connectionString); + + // Test the connection by getting wallet info + const info = await wallet.getInfo(); + + // Get initial balance + let balance: number | undefined; + try { + const balanceResult = await wallet.getBalance(); + balance = balanceResult.balance; + } catch (err) { + console.warn("[NWC] Failed to get balance:", err); + // Balance is optional, continue anyway + } + + // Get connection details from the wallet instance + const serialized = wallet.toJSON(); + + // Save connection to state + setNWCConnection({ + service: serialized.service, + relays: serialized.relays, + secret: serialized.secret, + lud16: serialized.lud16, + balance, + info: { + alias: info.alias, + methods: info.methods, + notifications: info.notifications, + }, + }); + + // Update balance if we got it + if (balance !== undefined) { + updateNWCBalance(balance); + } + + // Update info + updateNWCInfo({ + alias: info.alias, + methods: info.methods, + notifications: info.notifications, + }); + + // Show success toast + toast.success("Wallet Connected"); + + // Close dialog + onOpenChange(false); + } catch (err) { + console.error("Wallet connection error:", err); + setError(err instanceof Error ? err.message : "Failed to connect wallet"); + toast.error("Failed to connect wallet"); + } finally { + setLoading(false); + } + } + + return ( + + + + Connect Wallet + + Connect to a Nostr Wallet Connect (NWC) enabled Lightning wallet + + + +
+

+ Enter your wallet connection string. You can get this from your + wallet provider (Alby, Mutiny, etc.) +

+ + {error && ( +
+ + {error} +
+ )} + +
+ + setConnectionString(e.target.value)} + disabled={loading} + autoComplete="off" + /> +
+ + +
+
+
+ ); +} diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 4bb555e..0075e19 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -1,4 +1,4 @@ -import { User, HardDrive, Palette } from "lucide-react"; +import { User, HardDrive, Palette, Wallet, X, RefreshCw } from "lucide-react"; import accounts from "@/services/accounts"; import { useProfile } from "@/hooks/useProfile"; import { use$ } from "applesauce-react/hooks"; @@ -17,13 +17,23 @@ import { DropdownMenuSubTrigger, DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import Nip05 from "./nip05"; import { RelayLink } from "./RelayLink"; import SettingsDialog from "@/components/SettingsDialog"; import LoginDialog from "./LoginDialog"; +import ConnectWalletDialog from "@/components/ConnectWalletDialog"; import { useState } from "react"; import { useTheme } from "@/lib/themes"; +import { toast } from "sonner"; +import { useWallet } from "@/hooks/useWallet"; function UserAvatar({ pubkey }: { pubkey: string }) { const profile = useProfile(pubkey); @@ -56,13 +66,30 @@ function UserLabel({ pubkey }: { pubkey: string }) { export default function UserMenu() { const account = use$(accounts.active$); - const { state, addWindow } = useGrimoire(); + const { state, addWindow, disconnectNWC } = useGrimoire(); const relays = state.activeAccount?.relays; const blossomServers = state.activeAccount?.blossomServers; + const nwcConnection = state.nwcConnection; const [showSettings, setShowSettings] = useState(false); const [showLogin, setShowLogin] = useState(false); + const [showConnectWallet, setShowConnectWallet] = useState(false); + const [showWalletInfo, setShowWalletInfo] = useState(false); const { themeId, setTheme, availableThemes } = useTheme(); + // Get wallet service profile for display name, using wallet relays as hints + const walletServiceProfile = useProfile( + nwcConnection?.service, + nwcConnection?.relays, + ); + + // Use wallet hook for real-time balance and methods + const { + disconnect: disconnectWallet, + refreshBalance, + balance, + wallet, + } = useWallet(); + function openProfile() { if (!account?.pubkey) return; addWindow( @@ -77,10 +104,182 @@ export default function UserMenu() { accounts.removeAccount(account); } + function formatBalance(millisats?: number): string { + if (millisats === undefined) return "—"; + const sats = Math.floor(millisats / 1000); + return sats.toLocaleString(); + } + + function handleDisconnectWallet() { + // Disconnect from NWC service (stops notifications, clears wallet instance) + disconnectWallet(); + // Clear connection from state + disconnectNWC(); + setShowWalletInfo(false); + toast.success("Wallet disconnected"); + } + + async function handleRefreshBalance() { + try { + await refreshBalance(); + toast.success("Balance refreshed"); + } catch (_error) { + toast.error("Failed to refresh balance"); + } + } + + function getWalletName(): string { + if (!nwcConnection) return ""; + // Use service pubkey profile name, fallback to alias, then pubkey slice + return ( + getDisplayName(nwcConnection.service, walletServiceProfile) || + nwcConnection.info?.alias || + nwcConnection.service.slice(0, 8) + ); + } + + function openWalletServiceProfile() { + if (!nwcConnection?.service) return; + addWindow( + "profile", + { pubkey: nwcConnection.service }, + `Profile ${nwcConnection.service.slice(0, 8)}...`, + ); + setShowWalletInfo(false); + } + return ( <> + + + {/* Wallet Info Dialog */} + {nwcConnection && ( + + + + Wallet Info + + Connected Lightning wallet details + + + +
+ {/* Balance */} + {(balance !== undefined || + nwcConnection.balance !== undefined) && ( +
+ + Balance: + +
+ + {formatBalance(balance ?? nwcConnection.balance)} + + +
+
+ )} + + {/* Wallet Name */} +
+ Wallet: + +
+ + {/* Connection Status */} +
+ Status: +
+ + + {wallet ? "Connected" : "Disconnected"} + +
+
+ + {/* Lightning Address */} + {nwcConnection.lud16 && ( +
+ + Address: + + + {nwcConnection.lud16} + +
+ )} + + {/* Supported Methods */} + {nwcConnection.info?.methods && + nwcConnection.info.methods.length > 0 && ( +
+ + Supported Methods: + +
+ {nwcConnection.info.methods.map((method) => ( + + {method} + + ))} +
+
+ )} + + {/* Relays */} +
+ Relays: +
+ {nwcConnection.relays.map((relay) => ( + + ))} +
+
+ + {/* Disconnect Button */} + +
+
+
+ )} +