further refinement for new app builds

This commit is contained in:
Chad Curtis 2025-07-13 19:52:58 +00:00
parent 45ff37e752
commit 8c34dbc8d5
4 changed files with 175 additions and 63 deletions

View File

@ -655,6 +655,19 @@ export function Post(/* ...props */) {
Implement zaps with a payment method fallback chain: **NWC → WebLN → Manual**. Always validate recipient lightning addresses (`lud16`/`lud06`) before creating zap requests. Implement zaps with a payment method fallback chain: **NWC → WebLN → Manual**. Always validate recipient lightning addresses (`lud16`/`lud06`) before creating zap requests.
**⚠️ CRITICAL**: The `NWCProvider` must be included in the app's provider hierarchy for zap functionality to work. It should be placed inside `NostrProvider` but outside other UI providers:
```tsx
// In App.tsx and TestApp.tsx
import { NWCProvider } from '@/contexts/NWCContext';
<NostrProvider>
<NWCProvider>
{/* other providers and app content */}
</NWCProvider>
</NostrProvider>
```
```tsx ```tsx
// Use unified wallet detection // Use unified wallet detection
const { webln, activeNWC, preferredMethod } = useWallet(); const { webln, activeNWC, preferredMethod } = useWallet();
@ -667,6 +680,7 @@ if (!author.metadata?.lud16 && !author.metadata?.lud06) {
``` ```
**Critical patterns:** **Critical patterns:**
- **Include NWCProvider** in the provider tree before using any zap functionality
- Detect WebLN only when needed (dialog open) - Detect WebLN only when needed (dialog open)
- Show payment method indicator to users - Show payment method indicator to users
- Handle errors gracefully with specific messaging - Handle errors gracefully with specific messaging

View File

@ -3,45 +3,59 @@ import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet'; import { useWallet } from '@/hooks/useWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { Zap } from 'lucide-react';
import type { Event } from 'nostr-tools'; import type { Event } from 'nostr-tools';
interface ZapButtonProps { interface ZapButtonProps {
target: Event; target: Event;
className?: string; className?: string;
showCount?: boolean; showCount?: boolean;
// New: option to pass pre-fetched zap data (for batch mode)
zapData?: { count: number; totalSats: number; isLoading?: boolean };
} }
export function ZapButton({ target, className = "text-xs ml-1", showCount = true }: ZapButtonProps) { export function ZapButton({
target,
className = "text-xs ml-1",
showCount = true,
zapData: externalZapData
}: ZapButtonProps) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { data: author } = useAuthor(target.pubkey); const { data: author } = useAuthor(target.pubkey);
const { webln, activeNWC } = useWallet(); const { webln, activeNWC } = useWallet();
const { zaps } = useZaps(target, webln, activeNWC);
// Only fetch data if not provided externally
const { getZapData, 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 // 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)) { if (!user || user.pubkey === target.pubkey || (!author?.metadata?.lud16 && !author?.metadata?.lud06)) {
return null; return null;
} }
const zapCount = zaps?.length || 0; // Use external data if provided, otherwise use fetched data
const totalSats = zaps?.reduce((total, zap) => { const zapInfo = externalZapData || getZapData(target.id);
// Extract amount from amount tag const { count: zapCount, totalSats } = zapInfo;
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1]; const dataLoading = 'isLoading' in zapInfo ? zapInfo.isLoading : false;
const showLoading = externalZapData?.isLoading || dataLoading || isLoading;
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 ( return (
<ZapDialog target={target}> <ZapDialog target={target}>
{showCount && zapCount > 0 && ( <div className={className}>
<span className={className}> <Zap className="h-4 w-4 mr-1" />
{totalSats > 0 ? `${totalSats.toLocaleString()}` : zapCount} <span className="text-xs">
</span> {showLoading ? (
'...'
) : showCount && zapCount > 0 ? (
totalSats > 0 ? `${totalSats.toLocaleString()}` : zapCount
) : (
'Zap'
)} )}
</span>
</div>
</ZapDialog> </ZapDialog>
); );
} }

View File

@ -111,10 +111,9 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm" className={`text-muted-foreground hover:text-yellow-600 ${className || ''}`}> <div className={`cursor-pointer ${className || ''}`}>
<Zap className={`h-4 w-4 ${children ? 'mr-1' : ''}`} />
{children} {children}
</Button> </div>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px]" data-testid="zap-modal"> <DialogContent className="sm:max-w-[425px]" data-testid="zap-modal">
<DialogHeader> <DialogHeader>

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
@ -6,7 +6,7 @@ import { useToast } from '@/hooks/useToast';
import { useNWC } from '@/hooks/useNWCContext'; import { useNWC } from '@/hooks/useNWCContext';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { NWCConnection } from '@/hooks/useNWC'; import type { NWCConnection } from '@/hooks/useNWC';
import { nip57, nip19 } from 'nostr-tools'; import { nip57 } from 'nostr-tools';
import type { Event } from 'nostr-tools'; import type { Event } from 'nostr-tools';
import type { WebLNProvider } from 'webln'; import type { WebLNProvider } from 'webln';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -40,64 +40,141 @@ function parseNWCUri(uri: string): NWCConnection | null {
} }
} }
export function useZaps(target: Event, webln: WebLNProvider | null, _nwcConnection: NWCConnection | null, onZapSuccess?: () => void) { export function useZaps(
targets: Event | Event[],
webln: WebLNProvider | null,
_nwcConnection: NWCConnection | null,
onZapSuccess?: () => void
) {
const { nostr } = useNostr(); const { nostr } = useNostr();
const { toast } = useToast(); const { toast } = useToast();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { config } = useAppContext(); const { config } = useAppContext();
const { mutate: publishEvent } = useNostrPublish(); const { mutate: publishEvent } = useNostrPublish();
const author = useAuthor(target?.pubkey);
// Normalize targets to array for consistent handling
const targetArray = useMemo(() =>
Array.isArray(targets) ? targets : (targets ? [targets] : []),
[targets]
);
const isBatchMode = Array.isArray(targets);
const primaryTarget = targetArray[0]; // For single-target operations like zapping
const author = useAuthor(primaryTarget?.pubkey);
const { sendPayment, getActiveConnection, connections, activeConnection } = useNWC(); const { sendPayment, getActiveConnection, connections, activeConnection } = useNWC();
const [isZapping, setIsZapping] = useState(false); const [isZapping, setIsZapping] = useState(false);
const [invoice, setInvoice] = useState<string | null>(null); const [invoice, setInvoice] = useState<string | null>(null);
const naddr = // Create query key based on mode
target.kind >= 30000 && target.kind < 40000 const queryKey = isBatchMode
? nip19.naddrEncode({ ? ['zaps-batch', targetArray.map(t => t.id).sort()]
identifier: target.tags.find((t) => t[0] === 'd')?.[1] || '', : ['zaps-single', primaryTarget.id];
pubkey: target.pubkey,
kind: target.kind,
})
: undefined;
const queryKey = naddr ? `naddr:${naddr}` : `event:${target.id}`; const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({
queryKey,
const { data: zaps, ...query } = useQuery<NostrEvent[], Error>({
queryKey: ['zaps', queryKey],
queryFn: async (c) => { queryFn: async (c) => {
if (!target.id && !naddr) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]); const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]);
const filters: NostrFilter[] = []; const filters: NostrFilter[] = [];
if (naddr) { if (isBatchMode) {
try { // Batch mode: get zaps for all events at once
const decoded = nip19.decode(naddr); const eventIds = targetArray.map(t => t.id).filter(Boolean);
if (decoded.type === 'naddr') { const addressableEvents = targetArray.filter(t => t.kind >= 30000 && t.kind < 40000);
const { kind, pubkey, identifier } = decoded.data;
if (eventIds.length > 0) {
filters.push({ filters.push({
kinds: [9735], kinds: [9735],
'#a': [`${kind}:${pubkey}:${identifier}`], '#e': eventIds,
}); });
} }
} catch (e) {
console.error("Invalid naddr", naddr, e); // Handle addressable events
if (addressableEvents.length > 0) {
const addresses = addressableEvents.map(event => {
const identifier = event.tags.find((t) => t[0] === 'd')?.[1] || '';
return `${event.kind}:${event.pubkey}:${identifier}`;
});
filters.push({
kinds: [9735],
'#a': addresses,
});
} }
} else {
// Single mode: get zaps for one event
const target = primaryTarget;
if (target.kind >= 30000 && target.kind < 40000) {
const identifier = target.tags.find((t) => t[0] === 'd')?.[1] || '';
filters.push({
kinds: [9735],
'#a': [`${target.kind}:${target.pubkey}:${identifier}`],
});
} else { } else {
filters.push({ filters.push({
kinds: [9735], kinds: [9735],
'#e': [target.id], '#e': [target.id],
}); });
} }
}
if (filters.length === 0) return []; if (filters.length === 0) return [];
const events = await nostr.query(filters, { signal }); const events = await nostr.query(filters, { signal });
return events; return events;
}, },
enabled: !!target.id || !!naddr, enabled: targetArray.length > 0 && targetArray.every(t => t.id),
}); });
// Process zap events into organized data
const zapData = useMemo(() => {
if (!zapEvents) return {};
const organized: Record<string, { count: number; totalSats: number; events: NostrEvent[] }> = {};
zapEvents.forEach(zap => {
// Find which event this zap is for
const eventTag = zap.tags.find(([name]) => name === 'e')?.[1];
const addressTag = zap.tags.find(([name]) => name === 'a')?.[1];
let targetId: string | undefined;
if (eventTag) {
targetId = eventTag;
} else if (addressTag) {
// Find the target event that matches this address
const target = targetArray.find(t => {
if (t.kind >= 30000 && t.kind < 40000) {
const identifier = t.tags.find((tag) => tag[0] === 'd')?.[1] || '';
const address = `${t.kind}:${t.pubkey}:${identifier}`;
return address === addressTag;
}
return false;
});
targetId = target?.id;
}
if (!targetId) return;
if (!organized[targetId]) {
organized[targetId] = { count: 0, totalSats: 0, events: [] };
}
organized[targetId].count++;
organized[targetId].events.push(zap);
// Extract amount from amount tag
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
if (amountTag) {
const sats = Math.floor(parseInt(amountTag) / 1000); // Convert millisats to sats
organized[targetId].totalSats += sats;
}
});
return organized;
}, [zapEvents, targetArray]);
// For single mode, return the data for the primary target
const singleTargetData = isBatchMode ? undefined : zapData[primaryTarget.id];
const zaps = singleTargetData?.events;
const zap = async (amount: number, comment: string) => { const zap = async (amount: number, comment: string) => {
if (amount <= 0) { if (amount <= 0) {
return; return;
@ -155,17 +232,17 @@ export function useZaps(target: Event, webln: WebLNProvider | null, _nwcConnecti
// Create zap request (unsigned, like the old implementation) // Create zap request (unsigned, like the old implementation)
const zapRequest = nip57.makeZapRequest({ const zapRequest = nip57.makeZapRequest({
profile: target.pubkey, profile: primaryTarget.pubkey,
event: target.id, event: primaryTarget.id,
amount: zapAmount, amount: zapAmount,
relays, relays,
comment: comment, comment: comment,
}); });
// Handle addressable events (restored from old implementation) // Handle addressable events
if (naddr) { if (primaryTarget.kind >= 30000 && primaryTarget.kind < 40000) {
const decoded = nip19.decode(naddr).data as nip19.AddressPointer; const identifier = primaryTarget.tags.find((t) => t[0] === 'd')?.[1] || '';
zapRequest.tags.push(["a", `${decoded.kind}:${decoded.pubkey}:${decoded.identifier}`]); zapRequest.tags.push(["a", `${primaryTarget.kind}:${primaryTarget.pubkey}:${identifier}`]);
zapRequest.tags = zapRequest.tags.filter(t => t[0] !== 'e'); zapRequest.tags = zapRequest.tags.filter(t => t[0] !== 'e');
} }
@ -299,6 +376,7 @@ export function useZaps(target: Event, webln: WebLNProvider | null, _nwcConnecti
}; };
return { return {
// Legacy single-target API (for backward compatibility)
zaps, zaps,
...query, ...query,
zap, zap,
@ -306,5 +384,12 @@ export function useZaps(target: Event, webln: WebLNProvider | null, _nwcConnecti
invoice, invoice,
setInvoice, setInvoice,
parseNWCUri, parseNWCUri,
// New batch API
zapData,
isBatchMode,
// Helper functions
getZapData: (eventId: string) => zapData[eventId] || { count: 0, totalSats: 0, events: [] },
}; };
} }