useZaps.ts
1 import { useState, useMemo, useEffect, useCallback } from 'react'; 2 import { useCurrentUser } from '@/hooks/useCurrentUser'; 3 import { useAuthor } from '@/hooks/useAuthor'; 4 import { useAppContext } from '@/hooks/useAppContext'; 5 import { useToast } from '@/hooks/useToast'; 6 import { useNWC } from '@/hooks/useNWCContext'; 7 import type { NWCConnection } from '@/hooks/useNWC'; 8 import { nip57 } from 'nostr-tools'; 9 import type { Event } from 'nostr-tools'; 10 import type { WebLNProvider } from '@webbtc/webln-types'; 11 import { useQuery, useQueryClient } from '@tanstack/react-query'; 12 import { useNostr } from '@nostrify/react'; 13 import type { NostrEvent } from '@nostrify/nostrify'; 14 15 export function useZaps( 16 target: Event | Event[], 17 webln: WebLNProvider | null, 18 _nwcConnection: NWCConnection | null, 19 onZapSuccess?: () => void 20 ) { 21 const { nostr } = useNostr(); 22 const { toast } = useToast(); 23 const { user } = useCurrentUser(); 24 const { config } = useAppContext(); 25 const queryClient = useQueryClient(); 26 27 // Handle the case where an empty array is passed (from ZapButton when external data is provided) 28 const actualTarget = Array.isArray(target) ? (target.length > 0 ? target[0] : null) : target; 29 30 const author = useAuthor(actualTarget?.pubkey); 31 const { sendPayment, getActiveConnection } = useNWC(); 32 const [isZapping, setIsZapping] = useState(false); 33 const [invoice, setInvoice] = useState<string | null>(null); 34 35 // Cleanup state when component unmounts 36 useEffect(() => { 37 return () => { 38 setIsZapping(false); 39 setInvoice(null); 40 }; 41 }, []); 42 43 const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({ 44 queryKey: ['zaps', actualTarget?.id], 45 staleTime: 30000, // 30 seconds 46 refetchInterval: (query) => { 47 // Only refetch if the query is currently being observed (component is mounted) 48 return query.getObserversCount() > 0 ? 60000 : false; 49 }, 50 queryFn: async (c) => { 51 if (!actualTarget) return []; 52 53 const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); 54 55 // Query for zap receipts for this specific event 56 if (actualTarget.kind >= 30000 && actualTarget.kind < 40000) { 57 // Addressable event 58 const identifier = actualTarget.tags.find((t) => t[0] === 'd')?.[1] || ''; 59 const events = await nostr.query([{ 60 kinds: [9735], 61 '#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`], 62 }], { signal }); 63 return events; 64 } else { 65 // Regular event 66 const events = await nostr.query([{ 67 kinds: [9735], 68 '#e': [actualTarget.id], 69 }], { signal }); 70 return events; 71 } 72 }, 73 enabled: !!actualTarget?.id, 74 }); 75 76 // Process zap events into simple counts and totals 77 const { zapCount, totalSats, zaps } = useMemo(() => { 78 if (!zapEvents || !Array.isArray(zapEvents) || !actualTarget) { 79 return { zapCount: 0, totalSats: 0, zaps: [] }; 80 } 81 82 let count = 0; 83 let sats = 0; 84 85 zapEvents.forEach(zap => { 86 count++; 87 88 // Try multiple methods to extract the amount: 89 90 // Method 1: amount tag (from zap request, sometimes copied to receipt) 91 const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1]; 92 if (amountTag) { 93 const millisats = parseInt(amountTag); 94 sats += Math.floor(millisats / 1000); 95 return; 96 } 97 98 // Method 2: Extract from bolt11 invoice 99 const bolt11Tag = zap.tags.find(([name]) => name === 'bolt11')?.[1]; 100 if (bolt11Tag) { 101 try { 102 const invoiceSats = nip57.getSatoshisAmountFromBolt11(bolt11Tag); 103 sats += invoiceSats; 104 return; 105 } catch (error) { 106 console.warn('Failed to parse bolt11 amount:', error); 107 } 108 } 109 110 // Method 3: Parse from description (zap request JSON) 111 const descriptionTag = zap.tags.find(([name]) => name === 'description')?.[1]; 112 if (descriptionTag) { 113 try { 114 const zapRequest = JSON.parse(descriptionTag); 115 const requestAmountTag = zapRequest.tags?.find(([name]: string[]) => name === 'amount')?.[1]; 116 if (requestAmountTag) { 117 const millisats = parseInt(requestAmountTag); 118 sats += Math.floor(millisats / 1000); 119 return; 120 } 121 } catch (error) { 122 console.warn('Failed to parse description JSON:', error); 123 } 124 } 125 126 console.warn('Could not extract amount from zap receipt:', zap.id); 127 }); 128 129 130 return { zapCount: count, totalSats: sats, zaps: zapEvents }; 131 }, [zapEvents, actualTarget]); 132 133 const zap = async (amount: number, comment: string) => { 134 if (amount <= 0) { 135 return; 136 } 137 138 setIsZapping(true); 139 setInvoice(null); // Clear any previous invoice at the start 140 141 if (!user) { 142 toast({ 143 title: 'Login required', 144 description: 'You must be logged in to send a zap.', 145 variant: 'destructive', 146 }); 147 setIsZapping(false); 148 return; 149 } 150 151 if (!actualTarget) { 152 toast({ 153 title: 'Event not found', 154 description: 'Could not find the event to zap.', 155 variant: 'destructive', 156 }); 157 setIsZapping(false); 158 return; 159 } 160 161 try { 162 if (!author.data || !author.data?.metadata || !author.data?.event ) { 163 toast({ 164 title: 'Author not found', 165 description: 'Could not find the author of this item.', 166 variant: 'destructive', 167 }); 168 setIsZapping(false); 169 return; 170 } 171 172 const { lud06, lud16 } = author.data.metadata; 173 if (!lud06 && !lud16) { 174 toast({ 175 title: 'Lightning address not found', 176 description: 'The author does not have a lightning address configured.', 177 variant: 'destructive', 178 }); 179 setIsZapping(false); 180 return; 181 } 182 183 // Get zap endpoint using the old reliable method 184 const zapEndpoint = await nip57.getZapEndpoint(author.data.event); 185 if (!zapEndpoint) { 186 toast({ 187 title: 'Zap endpoint not found', 188 description: 'Could not find a zap endpoint for the author.', 189 variant: 'destructive', 190 }); 191 setIsZapping(false); 192 return; 193 } 194 195 // Create zap request - for all events, pass the event ID 196 const zapAmount = amount * 1000; // convert to millisats 197 198 const zapRequest = nip57.makeZapRequest({ 199 profile: actualTarget.pubkey, 200 event: actualTarget.id, 201 amount: zapAmount, 202 relays: config.relayMetadata.relays.map(r => r.url), 203 comment 204 }); 205 206 // Sign the zap request (but don't publish to relays - only send to LNURL endpoint) 207 if (!user.signer) { 208 throw new Error('No signer available'); 209 } 210 const signedZapRequest = await user.signer.signEvent(zapRequest); 211 212 try { 213 const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(signedZapRequest))}`); 214 const responseData = await res.json(); 215 216 if (!res.ok) { 217 throw new Error(`HTTP ${res.status}: ${responseData.reason || 'Unknown error'}`); 218 } 219 220 const newInvoice = responseData.pr; 221 if (!newInvoice || typeof newInvoice !== 'string') { 222 throw new Error('Lightning service did not return a valid invoice'); 223 } 224 225 // Get the current active NWC connection dynamically 226 const currentNWCConnection = getActiveConnection(); 227 228 // Try NWC first if available and properly connected 229 if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) { 230 try { 231 await sendPayment(currentNWCConnection, newInvoice); 232 233 // Clear states immediately on success 234 setIsZapping(false); 235 setInvoice(null); 236 237 toast({ 238 title: 'Zap successful!', 239 description: `You sent ${amount} sats via NWC to the author.`, 240 }); 241 242 // Invalidate zap queries to refresh counts 243 queryClient.invalidateQueries({ queryKey: ['zaps'] }); 244 245 // Close dialog last to ensure clean state 246 onZapSuccess?.(); 247 return; 248 } catch (nwcError) { 249 console.error('NWC payment failed, falling back:', nwcError); 250 251 // Show specific NWC error to user for debugging 252 const errorMessage = nwcError instanceof Error ? nwcError.message : 'Unknown NWC error'; 253 toast({ 254 title: 'NWC payment failed', 255 description: `${errorMessage}. Falling back to other payment methods...`, 256 variant: 'destructive', 257 }); 258 } 259 } 260 261 if (webln) { // Try WebLN next 262 try { 263 // For native WebLN, we may need to enable it first 264 let webLnProvider = webln; 265 if (webln.enable && typeof webln.enable === 'function') { 266 const enabledProvider = await webln.enable(); 267 // Some implementations return the provider, others return void 268 // Cast to WebLNProvider to handle both cases 269 const provider = enabledProvider as WebLNProvider | undefined; 270 if (provider) { 271 webLnProvider = provider; 272 } 273 } 274 275 await webLnProvider.sendPayment(newInvoice); 276 277 // Clear states immediately on success 278 setIsZapping(false); 279 setInvoice(null); 280 281 toast({ 282 title: 'Zap successful!', 283 description: `You sent ${amount} sats to the author.`, 284 }); 285 286 // Invalidate zap queries to refresh counts 287 queryClient.invalidateQueries({ queryKey: ['zaps'] }); 288 289 // Close dialog last to ensure clean state 290 onZapSuccess?.(); 291 } catch (weblnError) { 292 console.error('WebLN payment failed, falling back:', weblnError); 293 294 // Show specific WebLN error to user for debugging 295 const errorMessage = weblnError instanceof Error ? weblnError.message : 'Unknown WebLN error'; 296 toast({ 297 title: 'WebLN payment failed', 298 description: `${errorMessage}. Falling back to other payment methods...`, 299 variant: 'destructive', 300 }); 301 302 setInvoice(newInvoice); 303 setIsZapping(false); 304 } 305 } else { // Default - show QR code and manual Lightning URI 306 setInvoice(newInvoice); 307 setIsZapping(false); 308 } 309 } catch (err) { 310 console.error('Zap error:', err); 311 toast({ 312 title: 'Zap failed', 313 description: (err as Error).message, 314 variant: 'destructive', 315 }); 316 setIsZapping(false); 317 } 318 } catch (err) { 319 console.error('Zap error:', err); 320 toast({ 321 title: 'Zap failed', 322 description: (err as Error).message, 323 variant: 'destructive', 324 }); 325 setIsZapping(false); 326 } 327 }; 328 329 const resetInvoice = useCallback(() => { 330 setInvoice(null); 331 }, []); 332 333 return { 334 zaps, 335 zapCount, 336 totalSats, 337 ...query, 338 zap, 339 isZapping, 340 invoice, 341 setInvoice, 342 resetInvoice, 343 }; 344 }