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 */}
+
+
+ );
+}
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",