From 45ff37e7523414d6183b327dcf11ac8cc5b9fb67 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sun, 13 Jul 2025 17:53:30 +0000 Subject: [PATCH] messy, nwc support +_wallet fixes --- package-lock.json | 54 ++++- package.json | 2 + src/components/WalletModal.tsx | 72 ++++-- src/components/ZapButton.tsx | 47 ++++ src/components/ZapDialog.tsx | 17 ++ src/contexts/NWCContext.tsx | 8 + src/hooks/useNWC.ts | 393 +++++++++++++++++---------------- src/hooks/useNWCContext.ts | 15 ++ src/hooks/useWallet.ts | 56 +++-- src/hooks/useZaps.ts | 214 +++++++++++++----- 10 files changed, 594 insertions(+), 284 deletions(-) create mode 100644 src/components/ZapButton.tsx create mode 100644 src/contexts/NWCContext.tsx create mode 100644 src/hooks/useNWCContext.ts diff --git a/package-lock.json b/package-lock.json index 86e8f5a..68c26c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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.1", "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5", @@ -993,6 +995,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", @@ -6147,9 +6188,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", @@ -6157,9 +6198,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": { @@ -6175,8 +6214,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", diff --git a/package.json b/package.json index 4d1772a..c291d7d 100644 --- a/package.json +++ b/package.json @@ -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.1", "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5", diff --git a/src/components/WalletModal.tsx b/src/components/WalletModal.tsx index 8d3148c..be28642 100644 --- a/src/components/WalletModal.tsx +++ b/src/components/WalletModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Wallet, Plus, Trash2, Zap, Globe, Settings, CheckCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -15,7 +15,7 @@ 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/useNWC'; +import { useNWC } from '@/hooks/useNWCContext'; import { useWallet } from '@/hooks/useWallet'; import { useToast } from '@/hooks/useToast'; @@ -40,9 +40,23 @@ export function WalletModal({ children, className }: WalletModalProps) { setActiveConnection } = useNWC(); - const { hasWebLN, hasNWC, isDetecting } = useWallet(); + const { hasWebLN, isDetecting } = useWallet(); + + // Calculate hasNWC directly from connections to ensure reactivity + const hasNWC = connections.length > 0 && connections.some(c => c.isConnected); const { toast } = useToast(); + // Debug logging for wallet modal status + useEffect(() => { + console.debug('WalletModal status:', { + hasWebLN, + hasNWC, + connectionsCount: connections.length, + connectionsDetails: connections.map(c => ({ alias: c.alias, isConnected: c.isConnected })), + isDetecting + }); + }, [hasWebLN, hasNWC, connections, isDetecting]); + const handleAddConnection = async () => { if (!connectionUri.trim()) { toast({ @@ -53,33 +67,61 @@ export function WalletModal({ children, className }: WalletModalProps) { return; } + console.debug('WalletModal: Before adding connection', { + currentConnections: connections.length, + hasNWC + }); + setIsConnecting(true); try { const success = await addConnection(connectionUri.trim(), alias.trim() || undefined); if (success) { + console.debug('WalletModal: Connection added successfully', { + newConnections: connections.length, + hasNWC + }); setConnectionUri(''); setAlias(''); setAddDialogOpen(false); + + // Force a small delay to check state after React updates + setTimeout(() => { + console.debug('WalletModal: Post-add state check', { + connectionsLength: connections.length, + hasNWC + }); + }, 100); } } finally { setIsConnecting(false); } }; - const handleRemoveConnection = (walletPubkey: string) => { - removeConnection(walletPubkey); + const handleRemoveConnection = (connectionString: string) => { + console.debug('WalletModal: Before removing connection', { + currentConnections: connections.length, + hasNWC + }); + + removeConnection(connectionString); + + // Force a small delay to check state after React updates + setTimeout(() => { + console.debug('WalletModal: Post-remove state check', { + connectionsLength: connections.length, + hasNWC + }); + }, 100); }; - const handleSetActive = (walletPubkey: string) => { - setActiveConnection(walletPubkey); + const handleSetActive = (connectionString: string) => { + setActiveConnection(connectionString); toast({ title: 'Active wallet changed', description: 'The selected wallet is now active for zaps.', }); }; - - return ( @@ -208,11 +250,11 @@ export function WalletModal({ children, className }: WalletModalProps) { ) : (
{connections.map((connection) => { - const info = connectionInfo[connection.walletPubkey]; - const isActive = activeConnection === connection.walletPubkey; + const info = connectionInfo[connection.connectionString]; + const isActive = activeConnection === connection.connectionString; return ( -
+
@@ -220,7 +262,7 @@ export function WalletModal({ children, className }: WalletModalProps) { {connection.alias || info?.alias || 'Lightning Wallet'}

- {connection.walletPubkey.slice(0, 16)}... + NWC Connection

@@ -230,7 +272,7 @@ export function WalletModal({ children, className }: WalletModalProps) { @@ -238,7 +280,7 @@ export function WalletModal({ children, className }: WalletModalProps) { diff --git a/src/components/ZapButton.tsx b/src/components/ZapButton.tsx new file mode 100644 index 0000000..14273c8 --- /dev/null +++ b/src/components/ZapButton.tsx @@ -0,0 +1,47 @@ +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 type { Event } from 'nostr-tools'; + +interface ZapButtonProps { + target: Event; + className?: string; + showCount?: boolean; +} + +export function ZapButton({ target, className = "text-xs ml-1", showCount = true }: ZapButtonProps) { + const { user } = useCurrentUser(); + const { data: author } = useAuthor(target.pubkey); + const { webln, activeNWC } = useWallet(); + const { zaps } = useZaps(target, webln, activeNWC); + + // Don't show zap button if user is not logged in, is the author, or author has no lightning address + if (!user || user.pubkey === target.pubkey || (!author?.metadata?.lud16 && !author?.metadata?.lud06)) { + return null; + } + + const zapCount = zaps?.length || 0; + const totalSats = zaps?.reduce((total, zap) => { + // Extract amount from amount tag + const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1]; + + if (amountTag) { + return total + Math.floor(parseInt(amountTag) / 1000); // Convert millisats to sats + } + + // If no amount tag, don't count towards total + return total; + }, 0) || 0; + + return ( + + {showCount && zapCount > 0 && ( + + {totalSats > 0 ? `${totalSats.toLocaleString()}` : zapCount} + + )} + + ); +} \ No newline at end of file diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx index a4a541c..42fa596 100644 --- a/src/components/ZapDialog.tsx +++ b/src/components/ZapDialog.tsx @@ -41,6 +41,23 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) { const { data: author } = useAuthor(target.pubkey); const { toast } = useToast(); const { webln, activeNWC, hasWebLN, hasNWC, detectWebLN } = useWallet(); + + // Debug logging + useEffect(() => { + console.debug('ZapDialog wallet status:', { hasWebLN, hasNWC, activeNWC: !!activeNWC }); + }, [hasWebLN, hasNWC, activeNWC]); + + // Additional debug logging when dialog opens + useEffect(() => { + if (open) { + console.debug('ZapDialog opened with wallet status:', { + hasWebLN, + hasNWC, + activeNWC: activeNWC ? { alias: activeNWC.alias, isConnected: activeNWC.isConnected } : null + }); + } + }, [open, hasWebLN, hasNWC, activeNWC]); + const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false)); const [amount, setAmount] = useState(100); const [comment, setComment] = useState(''); diff --git a/src/contexts/NWCContext.tsx b/src/contexts/NWCContext.tsx new file mode 100644 index 0000000..43e866b --- /dev/null +++ b/src/contexts/NWCContext.tsx @@ -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 {children}; +} \ No newline at end of file diff --git a/src/hooks/useNWC.ts b/src/hooks/useNWC.ts index bad3de7..abbc8c8 100644 --- a/src/hooks/useNWC.ts +++ b/src/hooks/useNWC.ts @@ -1,16 +1,13 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useToast } from '@/hooks/useToast'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { nip04 } from 'nostr-tools'; -import { useNostr } from '@nostrify/react'; +import { LN } from '@getalby/sdk'; export interface NWCConnection { - walletPubkey: string; - secret: string; - relayUrls: string[]; - lud16?: string; + connectionString: string; alias?: string; + isConnected: boolean; + client?: LN; } interface NWCInfo { @@ -22,46 +19,37 @@ interface NWCInfo { notifications?: string[]; } -export function useNWC() { - const { nostr } = useNostr(); +export function useNWCInternal() { const { toast } = useToast(); - const { user } = useCurrentUser(); const [connections, setConnections] = useLocalStorage('nwc-connections', []); const [activeConnection, setActiveConnection] = useLocalStorage('nwc-active-connection', null); const [connectionInfo, setConnectionInfo] = useState>({}); - // Parse NWC URI - const parseNWCUri = (uri: string): NWCConnection | null => { + // Use connections directly - no filtering needed + + // Parse and validate NWC URI + const parseNWCUri = (uri: string): { connectionString: string } | null => { try { - const url = new URL(uri); - if (url.protocol !== 'nostr+walletconnect:') { + console.debug('Parsing NWC URI:', { uri: uri.substring(0, 50) + '...' }); + + if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) { + console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] }); return null; } - const walletPubkey = url.pathname.replace('//', ''); - const secret = url.searchParams.get('secret'); - const relayParam = url.searchParams.getAll('relay'); - const lud16 = url.searchParams.get('lud16') || undefined; - - if (!walletPubkey || !secret || relayParam.length === 0) { - return null; - } - - return { - walletPubkey, - secret, - relayUrls: relayParam, - lud16, - }; - } catch { + // Basic validation - let the SDK handle the detailed parsing + console.debug('NWC URI parsing successful'); + return { connectionString: uri }; + } catch (error) { + console.error('Failed to parse NWC URI:', error); return null; } }; // Add new connection const addConnection = async (uri: string, alias?: string): Promise => { - const connection = parseNWCUri(uri); - if (!connection) { + const parsed = parseNWCUri(uri); + if (!parsed) { toast({ title: 'Invalid NWC URI', description: 'Please check the connection string and try again.', @@ -71,7 +59,7 @@ export function useNWC() { } // Check if connection already exists - const existingConnection = connections.find(c => c.walletPubkey === connection.walletPubkey); + const existingConnection = connections.find(c => c.connectionString === parsed.connectionString); if (existingConnection) { toast({ title: 'Connection already exists', @@ -81,31 +69,85 @@ export function useNWC() { return false; } - if (alias) { - connection.alias = alias; - } - try { - // Test connection by fetching info - await fetchWalletInfo(connection); + console.debug('Testing NWC connection:', { uri: uri.substring(0, 50) + '...' }); - setConnections(prev => [...prev, connection]); + // Test the connection by creating an LN client with timeout + const testPromise = new Promise((resolve, reject) => { + try { + const client = new LN(parsed.connectionString); + resolve(client); + } catch (error) { + reject(error); + } + }); - // Set as active if it's the first connection - if (connections.length === 0) { - setActiveConnection(connection.walletPubkey); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Connection test timeout')), 10000); + }); + + const _client = await Promise.race([testPromise, timeoutPromise]) as LN; + + const connection: NWCConnection = { + connectionString: parsed.connectionString, + alias: alias || 'NWC Wallet', + isConnected: true, + // Don't store the client, create fresh ones for each payment + }; + + // Store basic connection info + setConnectionInfo(prev => ({ + ...prev, + [parsed.connectionString]: { + alias: connection.alias, + methods: ['pay_invoice'], // Assume basic payment capability + }, + })); + + const newConnections = [...connections, connection]; + setConnections(newConnections); + + console.debug('NWC connection added:', { + alias: connection.alias, + totalConnections: newConnections.length, + connectionString: parsed.connectionString.substring(0, 50) + '...', + isConnected: connection.isConnected + }); + + // Set as active if it's the first connection or no active connection is set + if (connections.length === 0 || !activeConnection) { + console.debug('Setting as active connection:', { + alias: connection.alias, + connectionString: parsed.connectionString.substring(0, 50) + '...', + previousActiveConnection: activeConnection + }); + setActiveConnection(parsed.connectionString); + console.debug('Active connection set to:', parsed.connectionString.substring(0, 50) + '...'); } + console.debug('NWC connection successful'); + + // Force a small delay to ensure state updates are processed + setTimeout(() => { + console.debug('Post-connection state check:', { + connectionsLength: connections.length + 1, // +1 because we just added one + newConnectionAlias: connection.alias + }); + }, 100); + toast({ title: 'Wallet connected', - description: `Successfully connected to ${alias || 'wallet'}.`, + description: `Successfully connected to ${connection.alias}.`, }); return true; - } catch { + } 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. Please check your connection.', + description: `Could not connect to the wallet: ${errorMessage}`, variant: 'destructive', }); return false; @@ -113,17 +155,23 @@ export function useNWC() { }; // Remove connection - const removeConnection = (walletPubkey: string) => { - setConnections(prev => prev.filter(c => c.walletPubkey !== walletPubkey)); + const removeConnection = (connectionString: string) => { + const filtered = connections.filter(c => c.connectionString !== connectionString); + setConnections(filtered); - if (activeConnection === walletPubkey) { - const remaining = connections.filter(c => c.walletPubkey !== walletPubkey); - setActiveConnection(remaining.length > 0 ? remaining[0].walletPubkey : null); + console.debug('NWC connection removed:', { + remainingConnections: filtered.length + }); + + if (activeConnection === connectionString) { + const newActive = filtered.length > 0 ? filtered[0].connectionString : null; + setActiveConnection(newActive); + console.debug('Active connection changed:', { newActive }); } setConnectionInfo(prev => { const newInfo = { ...prev }; - delete newInfo[walletPubkey]; + delete newInfo[connectionString]; return newInfo; }); @@ -134,156 +182,130 @@ export function useNWC() { }; // Get active connection - const getActiveConnection = (): NWCConnection | null => { - if (!activeConnection) return null; - return connections.find(c => c.walletPubkey === activeConnection) || null; - }; + const getActiveConnection = useCallback((): NWCConnection | null => { + console.debug('getActiveConnection called:', { + activeConnection, + connectionsLength: connections.length, + connections: connections.map(c => ({ alias: c.alias, connectionString: c.connectionString.substring(0, 50) + '...' })) + }); - // Send NWC request - const sendNWCRequest = useCallback(async ( + // If no active connection is set but we have connections, set the first one as active + if (!activeConnection && connections.length > 0) { + console.debug('Setting first connection as active:', connections[0].alias); + setActiveConnection(connections[0].connectionString); + return connections[0]; + } + + if (!activeConnection) { + console.debug('No active connection and no connections'); + return null; + } + + const found = connections.find(c => c.connectionString === activeConnection); + console.debug('Found active connection:', found ? found.alias : 'null'); + return found || null; + }, [activeConnection, connections, setActiveConnection]); + + // Send payment using the SDK + const sendPayment = useCallback(async ( connection: NWCConnection, - request: { method: string; params: Record } - ): Promise<{ result_type: string; error?: { code: string; message: string }; result?: unknown }> => { - if (!user?.signer) { - throw new Error('User not logged in or signer not available'); + invoice: string + ): Promise<{ preimage: string }> => { + if (!connection.connectionString) { + throw new Error('Invalid connection: missing connection string'); } - // Create request event - const requestEvent = { - kind: 23194, - created_at: Math.floor(Date.now() / 1000), - tags: [['p', connection.walletPubkey]], - content: await nip04.encrypt(connection.secret, connection.walletPubkey, JSON.stringify(request)), - }; - - // Sign and publish request - const signedRequest = await user.signer.signEvent(requestEvent); - if (!signedRequest) { - throw new Error('Failed to sign NWC request'); - } - - // Publish to NWC relays + // Always create a fresh client for each payment to avoid stale connections + let client: LN; try { - await nostr.event(signedRequest, { - signal: AbortSignal.timeout(10000), - relays: connection.relayUrls - }); + console.debug('Creating fresh NWC client for payment...'); + client = new LN(connection.connectionString); } catch (error) { - console.warn('Failed to publish NWC request:', error); - throw new Error('Failed to publish NWC request'); + console.error('Failed to create NWC client:', error); + throw new Error(`Failed to create NWC client: ${error instanceof Error ? error.message : 'Unknown error'}`); } - // Listen for response - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('NWC request timeout')); - }, 30000); // 30 second timeout + try { + console.debug('Sending payment via NWC SDK:', { + invoice: invoice.substring(0, 50) + '...', + connectionAlias: connection.alias + }); - // Query for response events - const checkForResponse = async () => { + // Add timeout to prevent hanging + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Payment timeout after 30 seconds')), 30000); + }); + + const paymentPromise = client.pay(invoice); + const response = await Promise.race([paymentPromise, timeoutPromise]) as { preimage: string }; + + console.debug('Payment successful:', { preimage: response.preimage }); + return response; + } catch (error) { + console.error('NWC payment failed:', error); + + // Provide more specific error messages + 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'); + } + }, []); + + // Get wallet info (simplified since SDK doesn't expose getInfo) + const getWalletInfo = useCallback(async (connection: NWCConnection): Promise => { + // Return stored info or basic fallback + const info = connectionInfo[connection.connectionString] || { + alias: connection.alias, + methods: ['pay_invoice'], + }; + return info; + }, [connectionInfo]); + + // Test NWC connection + const testNWCConnection = useCallback(async (connection: NWCConnection): Promise => { + if (!connection.connectionString) { + console.error('NWC connection test failed: missing connection string'); + return false; + } + + try { + console.debug('Testing NWC connection...', { alias: connection.alias }); + + // Create a fresh client for testing + const testPromise = new Promise((resolve, reject) => { try { - const responseEvents = await nostr.query([ - { - kinds: [23195], - authors: [connection.walletPubkey], - '#p': [user.pubkey], - '#e': [signedRequest.id], - since: Math.floor(Date.now() / 1000) - 60, - }, - ], { signal: AbortSignal.timeout(30000) }); - - for (const event of responseEvents) { - try { - const decrypted = await nip04.decrypt( - connection.secret, - connection.walletPubkey, - event.content - ); - const response = JSON.parse(decrypted); - clearTimeout(timeout); - resolve(response); - return; - } catch (error) { - console.error('Failed to decrypt NWC response:', error); - } - } - - // If no response found, wait and try again - setTimeout(checkForResponse, 2000); + const client = new LN(connection.connectionString); + resolve(client); } catch (error) { - clearTimeout(timeout); reject(error); } - }; + }); - // Start checking for responses - setTimeout(checkForResponse, 1000); // Wait 1 second before first check - }); - }, [nostr, user]); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Connection test timeout')), 5000); + }); - // Fetch wallet info - const fetchWalletInfo = useCallback(async (connection: NWCConnection): Promise => { - // First, try to get the info event (kind 13194) - try { - const infoEvents = await nostr.query([ - { - kinds: [13194], - authors: [connection.walletPubkey], - limit: 1, - } - ], { signal: AbortSignal.timeout(5000) }); + await Promise.race([testPromise, timeoutPromise]); - if (infoEvents.length > 0) { - const infoEvent = infoEvents[0]; - const capabilities = infoEvent.content.split(' '); - const notificationsTag = infoEvent.tags.find(tag => tag[0] === 'notifications'); - const notifications = notificationsTag ? notificationsTag[1].split(' ') : []; - - const info: NWCInfo = { - methods: capabilities, - notifications, - }; - - setConnectionInfo(prev => ({ - ...prev, - [connection.walletPubkey]: info, - })); - - return info; - } + console.debug('NWC connection test successful'); + return true; } catch (error) { - console.warn('Failed to fetch NWC info event:', error); + console.error('NWC connection test failed:', error); + return false; } + }, []); - // Fallback: try to send a get_info request - try { - const response = await sendNWCRequest(connection, { method: 'get_info', params: {} }); - if (response.error) { - throw new Error(response.error.message); - } - - const info = response.result as NWCInfo; - setConnectionInfo(prev => ({ - ...prev, - [connection.walletPubkey]: info, - })); - - return info; - } catch (error) { - console.error('Failed to fetch wallet info:', error); - throw error; - } - }, [nostr, sendNWCRequest]); - - // Fetch info for all connections on mount - useEffect(() => { - connections.forEach(connection => { - if (!connectionInfo[connection.walletPubkey]) { - fetchWalletInfo(connection).catch(console.error); - } - }); - }, [connections, connectionInfo, fetchWalletInfo]); return { connections, @@ -293,8 +315,9 @@ export function useNWC() { removeConnection, setActiveConnection, getActiveConnection, - fetchWalletInfo, - sendNWCRequest, + sendPayment, + getWalletInfo, parseNWCUri, + testNWCConnection, }; } \ No newline at end of file diff --git a/src/hooks/useNWCContext.ts b/src/hooks/useNWCContext.ts new file mode 100644 index 0000000..08304dc --- /dev/null +++ b/src/hooks/useNWCContext.ts @@ -0,0 +1,15 @@ +import { useContext } from 'react'; +import { createContext } from 'react'; +import { useNWCInternal } from '@/hooks/useNWC'; + +type NWCContextType = ReturnType; + +export const NWCContext = createContext(null); + +export function useNWC(): NWCContextType { + const context = useContext(NWCContext); + if (!context) { + throw new Error('useNWC must be used within a NWCProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index ceedcfe..69316f9 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useNWC } from '@/hooks/useNWC'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNWC } from '@/hooks/useNWCContext'; import type { WebLNProvider } from 'webln'; import { requestProvider } from 'webln'; @@ -15,37 +15,46 @@ export interface WalletStatus { export function useWallet() { const [webln, setWebln] = useState(null); const [isDetecting, setIsDetecting] = useState(false); - const { getActiveConnection } = useNWC(); - + 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) { - console.warn('WebLN not available:', 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]); - // Auto-detect on mount + // Only auto-detect once on mount, don't spam detection useEffect(() => { - detectWebLN(); - }, [detectWebLN]); + if (!hasAttemptedDetection) { + detectWebLN(); + } + }, [detectWebLN, hasAttemptedDetection]); // Test WebLN connection const testWebLN = useCallback(async (): Promise => { if (!webln) return false; - + try { await webln.enable(); return true; @@ -55,24 +64,41 @@ export function useWallet() { } }, [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' + const preferredMethod: WalletStatus['preferredMethod'] = activeNWC + ? 'nwc' + : webln + ? 'webln' : 'manual'; const status: WalletStatus = { hasWebLN: !!webln, - hasNWC: !!activeNWC, + hasNWC, webln, activeNWC, isDetecting, preferredMethod, }; + // Debug logging for wallet status changes + useEffect(() => { + console.debug('Wallet status updated:', { + hasWebLN: status.hasWebLN, + hasNWC: status.hasNWC, + connectionsCount: connections.length, + activeNWC: !!status.activeNWC, + preferredMethod: status.preferredMethod + }); + }, [status.hasWebLN, status.hasNWC, connections.length, status.activeNWC, status.preferredMethod]); + return { ...status, + hasAttemptedDetection, detectWebLN, testWebLN, }; diff --git a/src/hooks/useZaps.ts b/src/hooks/useZaps.ts index af24b08..8d8d969 100644 --- a/src/hooks/useZaps.ts +++ b/src/hooks/useZaps.ts @@ -3,12 +3,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useAppContext } from '@/hooks/useAppContext'; import { useToast } from '@/hooks/useToast'; -import { useNWC } from '@/hooks/useNWC'; +import { useNWC } from '@/hooks/useNWCContext'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; import type { NWCConnection } from '@/hooks/useNWC'; import { nip57, nip19 } from 'nostr-tools'; import type { Event } from 'nostr-tools'; import type { WebLNProvider } from 'webln'; -import { LNURL } from '@nostrify/nostrify/ln'; import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; @@ -24,30 +24,30 @@ function parseNWCUri(uri: string): NWCConnection | null { const walletPubkey = url.pathname.replace('//', ''); const secret = url.searchParams.get('secret'); const relayParam = url.searchParams.getAll('relay'); - const lud16 = url.searchParams.get('lud16') || undefined; + const _lud16 = url.searchParams.get('lud16') || undefined; if (!walletPubkey || !secret || relayParam.length === 0) { return null; } return { - walletPubkey, - secret, - relayUrls: relayParam, - lud16, + connectionString: uri, + alias: 'Parsed NWC', + isConnected: false, }; } catch { return null; } } -export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnection: NWCConnection | null, onZapSuccess?: () => void) { +export function useZaps(target: Event, webln: WebLNProvider | null, _nwcConnection: NWCConnection | null, onZapSuccess?: () => void) { const { nostr } = useNostr(); const { toast } = useToast(); const { user } = useCurrentUser(); const { config } = useAppContext(); + const { mutate: publishEvent } = useNostrPublish(); const author = useAuthor(target?.pubkey); - const { sendNWCRequest } = useNWC(); + const { sendPayment, getActiveConnection, connections, activeConnection } = useNWC(); const [isZapping, setIsZapping] = useState(false); const [invoice, setInvoice] = useState(null); @@ -104,6 +104,7 @@ export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnectio } setIsZapping(true); + setInvoice(null); // Clear any previous invoice at the start if (!user) { toast({ @@ -126,74 +127,166 @@ export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnectio return; } - const { lud06, lud16 } = author.data.metadata; - if (!lud16 && !lud06) { + const { lud16 } = author.data.metadata; + if (!lud16) { toast({ title: 'Lightning address not found', - description: 'The author does not have a lightning address (lud16 or lud06) configured.', + description: 'The author does not have a lightning address configured.', variant: 'destructive', }); setIsZapping(false); return; } - const lnurl = lud06 ? LNURL.fromString(lud06) : LNURL.fromLightningAddress(lud16!); - const zapAmount = amount * 1000; // convert to millisats - const zapRequest = await user.signer.signEvent(nip57.makeZapRequest({ - profile: target.pubkey, - event: target, - amount: zapAmount, - relays: [config.relayUrl], - comment: comment, - })); + // Get zap endpoint using the old reliable method + const zapEndpoint = await nip57.getZapEndpoint(author.data.event as Event); + if (!zapEndpoint) { + toast({ + title: 'Zap endpoint not found', + description: 'Could not find a zap endpoint for the author.', + variant: 'destructive', + }); + setIsZapping(false); + return; + } - const { pr: newInvoice } = await lnurl.getInvoice({ + const zapAmount = amount * 1000; // convert to millisats + const relays = [config.relayUrl]; + + // Create zap request (unsigned, like the old implementation) + const zapRequest = nip57.makeZapRequest({ + profile: target.pubkey, + event: target.id, amount: zapAmount, - nostr: zapRequest, + relays, + comment: comment, }); - // Try NWC first if available - if (nwcConnection) { - try { - const response = await sendNWCRequest(nwcConnection, { - method: 'pay_invoice', - params: { - invoice: newInvoice, - amount: zapAmount, - }, - }); + // Handle addressable events (restored from old implementation) + if (naddr) { + const decoded = nip19.decode(naddr).data as nip19.AddressPointer; + zapRequest.tags.push(["a", `${decoded.kind}:${decoded.pubkey}:${decoded.identifier}`]); + zapRequest.tags = zapRequest.tags.filter(t => t[0] !== 'e'); + } - if (response.error) { - throw new Error(`NWC Error: ${response.error.message}`); + // Sign and publish the zap request + publishEvent(zapRequest, { + onSuccess: async (event) => { + try { + // Use the old fetch method - more reliable than LNURL validation + const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(event))}`); + 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(); + + console.debug('Zap payment - detailed state check:', { + // Raw state + connectionsLength: connections.length, + activeConnectionString: activeConnection ? activeConnection.substring(0, 50) + '...' : null, + + // Connection details + connections: connections.map(c => ({ + alias: c.alias, + isConnected: c.isConnected, + connectionString: c.connectionString.substring(0, 50) + '...' + })), + + // getActiveConnection result + currentNWCConnection: currentNWCConnection ? { + alias: currentNWCConnection.alias, + isConnected: currentNWCConnection.isConnected, + connectionString: currentNWCConnection.connectionString.substring(0, 50) + '...' + } : null + }); + + // Try NWC first if available and properly connected + if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) { + try { + console.debug('Attempting NWC payment...', { + amount, + alias: currentNWCConnection.alias, + invoiceLength: newInvoice.length + }); + + const response = await sendPayment(currentNWCConnection, newInvoice); + + console.debug('NWC payment successful:', { preimage: response.preimage }); + + // Clear states immediately on success + setIsZapping(false); + setInvoice(null); + + toast({ + title: 'Zap successful!', + description: `You sent ${amount} sats via NWC to the author.`, + }); + + // 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', + }); + } + } + + // Fallback to WebLN or manual payment + if (webln) { + 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.`, + }); + + // Close dialog last to ensure clean state + onZapSuccess?.(); + } else { + setInvoice(newInvoice); + setIsZapping(false); + } + } catch (err) { + console.error('Zap error:', err); + toast({ + title: 'Zap failed', + description: (err as Error).message, + variant: 'destructive', + }); + } finally { + setIsZapping(false); } - + }, + onError: (err) => { + console.error('Failed to publish zap request:', err); toast({ - title: 'Zap successful!', - description: `You sent ${amount} sats via NWC to the author.`, - }); - onZapSuccess?.(); - return; - } catch (nwcError) { - console.error('NWC payment failed, falling back:', nwcError); - toast({ - title: 'NWC payment failed', - description: 'Falling back to manual payment...', + title: 'Zap failed', + description: 'Failed to create zap request', variant: 'destructive', }); - } - } - - // Fallback to WebLN or manual payment - if (webln) { - await webln.sendPayment(newInvoice); - toast({ - title: 'Zap successful!', - description: `You sent ${amount} sats to the author.`, - }); - onZapSuccess?.(); - } else { - setInvoice(newInvoice); - } + setIsZapping(false); + }, + }); } catch (err) { console.error('Zap error:', err); toast({ @@ -201,7 +294,6 @@ export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnectio description: (err as Error).message, variant: 'destructive', }); - } finally { setIsZapping(false); } };