/ src / hooks / useZaps.ts
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  }