diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 068aae6..93b0220 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -303,6 +303,34 @@ function generateRawCommand(appId: string, props: any): string { case "spells": return "spells"; + case "zap": + if (props.recipientPubkey) { + try { + const npub = nip19.npubEncode(props.recipientPubkey); + let result = `zap ${npub}`; + if (props.eventPointer) { + if ("id" in props.eventPointer) { + const nevent = nip19.neventEncode({ id: props.eventPointer.id }); + result += ` ${nevent}`; + } else if ( + "kind" in props.eventPointer && + "pubkey" in props.eventPointer + ) { + const naddr = nip19.naddrEncode({ + kind: props.eventPointer.kind, + pubkey: props.eventPointer.pubkey, + identifier: props.eventPointer.identifier || "", + }); + result += ` ${naddr}`; + } + } + return result; + } catch { + return `zap ${props.recipientPubkey.slice(0, 16)}...`; + } + } + return "zap"; + default: return appId; } diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 4d4671f..6d1bc6b 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -43,6 +43,9 @@ const BlossomViewer = lazy(() => import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), ); const WalletViewer = lazy(() => import("./WalletViewer")); +const ZapWindow = lazy(() => + import("./ZapWindow").then((m) => ({ default: m.ZapWindow })), +); const CountViewer = lazy(() => import("./CountViewer")); // Loading fallback component @@ -226,6 +229,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { case "wallet": content = ; break; + case "zap": + content = ( + + ); + break; default: content = (
diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx new file mode 100644 index 0000000..bdf7787 --- /dev/null +++ b/src/components/ZapWindow.tsx @@ -0,0 +1,428 @@ +/** + * ZapWindow Component + * + * UI for sending Lightning zaps to Nostr users and events (NIP-57) + * + * Features: + * - Send zaps to profiles or events + * - Preset and custom amounts + * - Remembers most-used amounts + * - NWC wallet payment or QR code fallback + * - Shows feed render of zapped event + */ + +import { useState, useEffect, useMemo } from "react"; +import { toast } from "sonner"; +import { + Zap, + Wallet, + QrCode, + Copy, + ExternalLink, + Loader2, + CheckCircle2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import QRCode from "qrcode"; +import { useProfile } from "applesauce-react/hooks"; +import { use$ } from "applesauce-react/hooks"; +import eventStore from "@/services/event-store"; +import { useWallet } from "@/hooks/useWallet"; +import { getProfileContent } from "applesauce-core/helpers"; +import { getDisplayName } from "@/lib/nostr-utils"; +import { KindRenderer } from "./nostr/kinds"; +import type { EventPointer, AddressPointer } from "@/lib/open-parser"; +import { isAddressableKind } from "applesauce-core/helpers"; +import type { NostrEvent } from "@/types/nostr"; + +export interface ZapWindowProps { + /** Recipient pubkey (who receives the zap) */ + recipientPubkey: string; + /** Optional event being zapped (adds context) */ + eventPointer?: EventPointer | AddressPointer; +} + +// Default preset amounts in sats +const DEFAULT_PRESETS = [21, 100, 500, 1000, 5000, 10000]; + +// LocalStorage keys +const STORAGE_KEY_CUSTOM_AMOUNTS = "grimoire_zap_custom_amounts"; +const STORAGE_KEY_AMOUNT_USAGE = "grimoire_zap_amount_usage"; + +export function ZapWindow({ + recipientPubkey: initialRecipientPubkey, + eventPointer, +}: ZapWindowProps) { + // Load event if we have a pointer and no recipient pubkey (derive from event author) + const event = use$(() => { + if (!eventPointer) return undefined; + if ("id" in eventPointer) { + return eventStore.event(eventPointer.id); + } + // AddressPointer + return eventStore.replaceable( + eventPointer.kind, + eventPointer.pubkey, + eventPointer.identifier, + ); + }, [eventPointer]); + + // Resolve recipient: use provided pubkey or derive from event author + const recipientPubkey = initialRecipientPubkey || event?.pubkey || ""; + + const recipientProfile = useProfile(recipientPubkey, eventStore); + + const { wallet, walletInfo, payInvoice, refreshBalance } = useWallet(); + + const [selectedAmount, setSelectedAmount] = useState(null); + const [customAmount, setCustomAmount] = useState(""); + const [comment, setComment] = useState(""); + const [isProcessing, setIsProcessing] = useState(false); + const [isPaid, setIsPaid] = useState(false); + const [qrCodeUrl, setQrCodeUrl] = useState(""); + const [invoice, setInvoice] = useState(""); + const [showQrDialog, setShowQrDialog] = useState(false); + + // Load custom amounts and usage stats from localStorage + const [customAmounts, setCustomAmounts] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY_CUSTOM_AMOUNTS); + return stored ? JSON.parse(stored) : []; + }); + + const [amountUsage, setAmountUsage] = useState>(() => { + const stored = localStorage.getItem(STORAGE_KEY_AMOUNT_USAGE); + return stored ? JSON.parse(stored) : {}; + }); + + // Combine preset and custom amounts, sort by usage + const availableAmounts = useMemo(() => { + const all = [...DEFAULT_PRESETS, ...customAmounts]; + const unique = Array.from(new Set(all)); + // Sort by usage count (descending), then by amount + return unique.sort((a, b) => { + const usageA = amountUsage[a] || 0; + const usageB = amountUsage[b] || 0; + if (usageA !== usageB) return usageB - usageA; + return a - b; + }); + }, [customAmounts, amountUsage]); + + // Get recipient name for display + const recipientName = useMemo(() => { + const content = recipientProfile + ? getProfileContent(recipientProfile) + : null; + return content + ? getDisplayName(recipientPubkey, content) + : recipientPubkey.slice(0, 8); + }, [recipientPubkey, recipientProfile]); + + // Get event author name if zapping an event + const eventAuthorName = useMemo(() => { + if (!event) return null; + const authorProfile = eventStore.getReplaceable(0, event.pubkey); + const content = authorProfile ? getProfileContent(authorProfile) : null; + return content + ? getDisplayName(event.pubkey, content) + : event.pubkey.slice(0, 8); + }, [event]); + + // Track amount usage + const trackAmountUsage = (amount: number) => { + const newUsage = { + ...amountUsage, + [amount]: (amountUsage[amount] || 0) + 1, + }; + setAmountUsage(newUsage); + localStorage.setItem(STORAGE_KEY_AMOUNT_USAGE, JSON.stringify(newUsage)); + + // If it's a custom amount not in our list, add it + if (!DEFAULT_PRESETS.includes(amount) && !customAmounts.includes(amount)) { + const newCustomAmounts = [...customAmounts, amount]; + setCustomAmounts(newCustomAmounts); + localStorage.setItem( + STORAGE_KEY_CUSTOM_AMOUNTS, + JSON.stringify(newCustomAmounts), + ); + } + }; + + // Handle zap payment flow + const handleZap = async (useWallet: boolean) => { + const amount = selectedAmount || parseInt(customAmount); + if (!amount || amount <= 0) { + toast.error("Please enter a valid amount"); + return; + } + + setIsProcessing(true); + try { + // Track usage + trackAmountUsage(amount); + + // Step 1: Get Lightning address from recipient profile + const content = recipientProfile + ? getProfileContent(recipientProfile) + : null; + const lud16 = content?.lud16; + const lud06 = content?.lud06; + + if (!lud16 && !lud06) { + throw new Error("Recipient has no Lightning address configured"); + } + + // Step 2: Resolve LNURL to get callback URL + // TODO: Implement full LNURL resolution and zap request creation + // For now, show a placeholder message + toast.error( + "Zap functionality coming soon! Need to implement LNURL resolution and zap request creation.", + ); + + // Placeholder for full implementation: + // 1. Resolve LNURL (lud16 or lud06) to get callback URL and nostrPubkey + // 2. Create kind 9734 zap request event + // 3. Sign zap request with user's key + // 4. Send GET request to callback with zap request and amount + // 5. Get invoice from callback + // 6. If useWallet: pay invoice with NWC + // Else: show QR code + // 7. Listen for kind 9735 receipt (optional) + } catch (error) { + console.error("Zap error:", error); + toast.error( + error instanceof Error ? error.message : "Failed to send zap", + ); + } finally { + setIsProcessing(false); + } + }; + + // Copy to clipboard + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + } catch { + toast.error("Failed to copy"); + } + }; + + // Open in wallet + const openInWallet = (invoice: string) => { + window.open(`lightning:${invoice}`, "_blank"); + }; + + return ( +
+ {/* Header */} +
+
+ +
+

+ Zap {eventAuthorName || recipientName} +

+ {event && ( +

+ For their{" "} + {event.kind === 1 ? "note" : `kind ${event.kind} event`} +

+ )} +
+
+
+ +
+
+ {/* Show event preview if zapping an event */} + {event && ( + + + + Zapping Event + + + + + + + )} + + {/* Amount Selection */} + + + Amount (sats) + + + {/* Preset amounts */} +
+ {availableAmounts.map((amount) => ( + + ))} +
+ + {/* Custom amount */} +
+ + { + setCustomAmount(e.target.value); + setSelectedAmount(null); + }} + min="1" + /> +
+ + {/* Comment */} +
+ + setComment(e.target.value)} + maxLength={200} + /> +
+
+
+ + {/* Payment Methods */} + + + Payment Method + + + {wallet && walletInfo?.methods.includes("pay_invoice") ? ( + + ) : ( +
+ Connect a wallet to pay directly +
+ )} + + +
+
+
+
+ + {/* QR Code Dialog */} + + + + Lightning Invoice + + Scan with your Lightning wallet or copy the invoice + + + +
+ {qrCodeUrl && ( +
+ Lightning Invoice QR Code +
+ )} + +
+ +
+ + +
+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/constants/command-icons.ts b/src/constants/command-icons.ts index c45d5f9..860963f 100644 --- a/src/constants/command-icons.ts +++ b/src/constants/command-icons.ts @@ -16,6 +16,7 @@ import { Wifi, MessageSquare, Hash, + Zap, type LucideIcon, } from "lucide-react"; @@ -80,6 +81,10 @@ export const COMMAND_ICONS: Record = { icon: MessageSquare, description: "Join and participate in NIP-29 relay-based group chats", }, + zap: { + icon: Zap, + description: "Send a Lightning zap to a Nostr user or event", + }, // Utility commands encode: { diff --git a/src/lib/zap-parser.ts b/src/lib/zap-parser.ts new file mode 100644 index 0000000..060ada7 --- /dev/null +++ b/src/lib/zap-parser.ts @@ -0,0 +1,187 @@ +import { nip19 } from "nostr-tools"; +import { isNip05, resolveNip05 } from "./nip05"; +import { + isValidHexPubkey, + isValidHexEventId, + normalizeHex, +} from "./nostr-validation"; +import { normalizeRelayURL } from "./relay-url"; +import type { EventPointer, AddressPointer } from "./open-parser"; + +export interface ParsedZapCommand { + /** Recipient pubkey (who receives the zap) */ + recipientPubkey: string; + /** Optional event being zapped (adds context to the zap) */ + eventPointer?: EventPointer | AddressPointer; +} + +/** + * Parse ZAP command arguments + * + * Supports: + * - `zap ` - Zap a person + * - `zap ` - Zap an event (recipient derived from event author) + * - `zap ` - Zap a specific person for a specific event + * + * Profile formats: npub, nprofile, hex pubkey, user@domain.com, $me + * Event formats: note, nevent, naddr, hex event ID + */ +export async function parseZapCommand( + args: string[], + activeAccountPubkey?: string, +): Promise { + if (args.length === 0) { + throw new Error( + "Recipient or event required. Usage: zap or zap or zap ", + ); + } + + const firstArg = args[0]; + const secondArg = args[1]; + + // Case 1: Two arguments - zap + if (secondArg) { + const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey); + const eventPointer = parseEventPointer(secondArg); + return { recipientPubkey, eventPointer }; + } + + // Case 2: One argument - try event first, then profile + // Events have more specific patterns (nevent, naddr, note) + const eventPointer = tryParseEventPointer(firstArg); + if (eventPointer) { + // For events, we'll need to fetch the event to get the author + // For now, we'll return a placeholder and let the component fetch it + return { + recipientPubkey: "", // Will be filled in by component from event author + eventPointer, + }; + } + + // Must be a profile + const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey); + return { recipientPubkey }; +} + +/** + * Parse a profile identifier into a pubkey + */ +async function parseProfile( + identifier: string, + activeAccountPubkey?: string, +): Promise { + // Handle $me alias + if (identifier.toLowerCase() === "$me") { + if (!activeAccountPubkey) { + throw new Error("No active account. Please log in to use $me alias."); + } + return activeAccountPubkey; + } + + // Try bech32 decode (npub, nprofile) + if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) { + try { + const decoded = nip19.decode(identifier); + if (decoded.type === "npub") { + return decoded.data; + } + if (decoded.type === "nprofile") { + return decoded.data.pubkey; + } + } catch (error) { + throw new Error(`Invalid npub/nprofile: ${error}`); + } + } + + // Check if it's a hex pubkey + if (isValidHexPubkey(identifier)) { + return normalizeHex(identifier); + } + + // Check if it's a NIP-05 identifier + if (isNip05(identifier)) { + const pubkey = await resolveNip05(identifier); + if (!pubkey) { + throw new Error(`Failed to resolve NIP-05 identifier: ${identifier}`); + } + return pubkey; + } + + throw new Error( + `Invalid profile identifier: ${identifier}. Supported: npub, nprofile, hex pubkey, user@domain.com`, + ); +} + +/** + * Parse an event identifier into a pointer + */ +function parseEventPointer(identifier: string): EventPointer | AddressPointer { + const result = tryParseEventPointer(identifier); + if (!result) { + throw new Error( + `Invalid event identifier: ${identifier}. Supported: note, nevent, naddr, hex ID`, + ); + } + return result; +} + +/** + * Try to parse an event identifier, returning null if it doesn't match event patterns + */ +function tryParseEventPointer( + identifier: string, +): EventPointer | AddressPointer | null { + // Try bech32 decode (note, nevent, naddr) + if ( + identifier.startsWith("note") || + identifier.startsWith("nevent") || + identifier.startsWith("naddr") + ) { + try { + const decoded = nip19.decode(identifier); + + if (decoded.type === "note") { + return { id: decoded.data }; + } + + if (decoded.type === "nevent") { + return { + ...decoded.data, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }) + .filter((url): url is string => url !== null), + }; + } + + if (decoded.type === "naddr") { + return { + ...decoded.data, + relays: decoded.data.relays + ?.map((url) => { + try { + return normalizeRelayURL(url); + } catch { + return null; + } + }) + .filter((url): url is string => url !== null), + }; + } + } catch { + return null; + } + } + + // Check if it's a hex event ID + if (isValidHexEventId(identifier)) { + return { id: normalizeHex(identifier) }; + } + + return null; +} diff --git a/src/types/app.ts b/src/types/app.ts index 681b678..1f8b561 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -22,6 +22,7 @@ export type AppId = | "spellbooks" | "blossom" | "wallet" + | "zap" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index 6493e5e..230a292 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -8,6 +8,7 @@ import { parseRelayCommand } from "@/lib/relay-parser"; import { resolveNip05Batch, resolveDomainDirectoryBatch } from "@/lib/nip05"; import { parseChatCommand } from "@/lib/chat-parser"; import { parseBlossomCommand } from "@/lib/blossom-parser"; +import { parseZapCommand } from "@/lib/zap-parser"; export interface ManPageEntry { name: string; @@ -614,6 +615,38 @@ export const manPages: Record = { return parsed; }, }, + zap: { + name: "zap", + section: "1", + synopsis: "zap [event]", + description: + "Send a Lightning zap (NIP-57) to a Nostr user or event. Zaps are Lightning payments with proof published to Nostr. Supports zapping profiles directly or events with context. Requires the recipient to have a Lightning address (lud16/lud06) configured in their profile.", + options: [ + { + flag: "", + description: + "Recipient: npub, nprofile, hex pubkey, user@domain.com, $me", + }, + { + flag: "", + description: "Event to zap: note, nevent, naddr, hex ID (optional)", + }, + ], + examples: [ + "zap fiatjaf.com Zap a user by NIP-05", + "zap npub1... Zap a user by npub", + "zap nevent1... Zap an event (recipient = event author)", + "zap npub1... nevent1... Zap a specific user for a specific event", + "zap alice@domain.com naddr1... Zap with event context", + ], + seeAlso: ["profile", "open", "wallet"], + appId: "zap", + category: "Nostr", + argParser: async (args: string[], activeAccountPubkey?: string) => { + const parsed = await parseZapCommand(args, activeAccountPubkey); + return parsed; + }, + }, encode: { name: "encode", section: "1",