mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-27 04:59:22 +00:00
further refinement for new app builds
This commit is contained in:
parent
45ff37e752
commit
8c34dbc8d5
14
CONTEXT.md
14
CONTEXT.md
@ -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.
|
||||
|
||||
**⚠️ 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
|
||||
// Use unified wallet detection
|
||||
const { webln, activeNWC, preferredMethod } = useWallet();
|
||||
@ -667,6 +680,7 @@ if (!author.metadata?.lud16 && !author.metadata?.lud06) {
|
||||
```
|
||||
|
||||
**Critical patterns:**
|
||||
- **Include NWCProvider** in the provider tree before using any zap functionality
|
||||
- Detect WebLN only when needed (dialog open)
|
||||
- Show payment method indicator to users
|
||||
- Handle errors gracefully with specific messaging
|
||||
|
@ -3,45 +3,59 @@ import { useZaps } from '@/hooks/useZaps';
|
||||
import { useWallet } from '@/hooks/useWallet';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { Zap } from 'lucide-react';
|
||||
import type { Event } from 'nostr-tools';
|
||||
|
||||
interface ZapButtonProps {
|
||||
target: Event;
|
||||
className?: string;
|
||||
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 { data: author } = useAuthor(target.pubkey);
|
||||
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
|
||||
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;
|
||||
// Use external data if provided, otherwise use fetched data
|
||||
const zapInfo = externalZapData || getZapData(target.id);
|
||||
const { count: zapCount, totalSats } = zapInfo;
|
||||
const dataLoading = 'isLoading' in zapInfo ? zapInfo.isLoading : false;
|
||||
const showLoading = externalZapData?.isLoading || dataLoading || isLoading;
|
||||
|
||||
return (
|
||||
<ZapDialog target={target}>
|
||||
{showCount && zapCount > 0 && (
|
||||
<span className={className}>
|
||||
{totalSats > 0 ? `${totalSats.toLocaleString()}` : zapCount}
|
||||
</span>
|
||||
<div className={className}>
|
||||
<Zap className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs">
|
||||
{showLoading ? (
|
||||
'...'
|
||||
) : showCount && zapCount > 0 ? (
|
||||
totalSats > 0 ? `${totalSats.toLocaleString()}` : zapCount
|
||||
) : (
|
||||
'Zap'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</ZapDialog>
|
||||
);
|
||||
}
|
@ -111,10 +111,9 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={`text-muted-foreground hover:text-yellow-600 ${className || ''}`}>
|
||||
<Zap className={`h-4 w-4 ${children ? 'mr-1' : ''}`} />
|
||||
<div className={`cursor-pointer ${className || ''}`}>
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]" data-testid="zap-modal">
|
||||
<DialogHeader>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@ -6,7 +6,7 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { useNWC } from '@/hooks/useNWCContext';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
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 { WebLNProvider } from 'webln';
|
||||
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 { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
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 [isZapping, setIsZapping] = useState(false);
|
||||
const [invoice, setInvoice] = useState<string | null>(null);
|
||||
|
||||
const naddr =
|
||||
target.kind >= 30000 && target.kind < 40000
|
||||
? nip19.naddrEncode({
|
||||
identifier: target.tags.find((t) => t[0] === 'd')?.[1] || '',
|
||||
pubkey: target.pubkey,
|
||||
kind: target.kind,
|
||||
})
|
||||
: undefined;
|
||||
// Create query key based on mode
|
||||
const queryKey = isBatchMode
|
||||
? ['zaps-batch', targetArray.map(t => t.id).sort()]
|
||||
: ['zaps-single', primaryTarget.id];
|
||||
|
||||
const queryKey = naddr ? `naddr:${naddr}` : `event:${target.id}`;
|
||||
|
||||
const { data: zaps, ...query } = useQuery<NostrEvent[], Error>({
|
||||
queryKey: ['zaps', queryKey],
|
||||
const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({
|
||||
queryKey,
|
||||
queryFn: async (c) => {
|
||||
if (!target.id && !naddr) return [];
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]);
|
||||
|
||||
const filters: NostrFilter[] = [];
|
||||
|
||||
if (naddr) {
|
||||
try {
|
||||
const decoded = nip19.decode(naddr);
|
||||
if (decoded.type === 'naddr') {
|
||||
const { kind, pubkey, identifier } = decoded.data;
|
||||
if (isBatchMode) {
|
||||
// Batch mode: get zaps for all events at once
|
||||
const eventIds = targetArray.map(t => t.id).filter(Boolean);
|
||||
const addressableEvents = targetArray.filter(t => t.kind >= 30000 && t.kind < 40000);
|
||||
|
||||
if (eventIds.length > 0) {
|
||||
filters.push({
|
||||
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 {
|
||||
filters.push({
|
||||
kinds: [9735],
|
||||
'#e': [target.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.length === 0) return [];
|
||||
|
||||
const events = await nostr.query(filters, { signal });
|
||||
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) => {
|
||||
if (amount <= 0) {
|
||||
return;
|
||||
@ -155,17 +232,17 @@ export function useZaps(target: Event, webln: WebLNProvider | null, _nwcConnecti
|
||||
|
||||
// Create zap request (unsigned, like the old implementation)
|
||||
const zapRequest = nip57.makeZapRequest({
|
||||
profile: target.pubkey,
|
||||
event: target.id,
|
||||
profile: primaryTarget.pubkey,
|
||||
event: primaryTarget.id,
|
||||
amount: zapAmount,
|
||||
relays,
|
||||
comment: comment,
|
||||
});
|
||||
|
||||
// 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}`]);
|
||||
// Handle addressable events
|
||||
if (primaryTarget.kind >= 30000 && primaryTarget.kind < 40000) {
|
||||
const identifier = primaryTarget.tags.find((t) => t[0] === 'd')?.[1] || '';
|
||||
zapRequest.tags.push(["a", `${primaryTarget.kind}:${primaryTarget.pubkey}:${identifier}`]);
|
||||
zapRequest.tags = zapRequest.tags.filter(t => t[0] !== 'e');
|
||||
}
|
||||
|
||||
@ -299,6 +376,7 @@ export function useZaps(target: Event, webln: WebLNProvider | null, _nwcConnecti
|
||||
};
|
||||
|
||||
return {
|
||||
// Legacy single-target API (for backward compatibility)
|
||||
zaps,
|
||||
...query,
|
||||
zap,
|
||||
@ -306,5 +384,12 @@ export function useZaps(target: Event, webln: WebLNProvider | null, _nwcConnecti
|
||||
invoice,
|
||||
setInvoice,
|
||||
parseNWCUri,
|
||||
|
||||
// New batch API
|
||||
zapData,
|
||||
isBatchMode,
|
||||
|
||||
// Helper functions
|
||||
getZapData: (eventId: string) => zapData[eventId] || { count: 0, totalSats: 0, events: [] },
|
||||
};
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user