From c94203852e0df422573f64889d152193e27d743b Mon Sep 17 00:00:00 2001 From: Alejandro Date: Sun, 18 Jan 2026 21:52:44 +0100 Subject: [PATCH] feat: detect and display zap payments in NWC wallet viewer (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: detect and display zap payments in NWC wallet viewer Add intelligent zap payment detection and enhanced display in the NWC wallet transaction list and detail views. Changes: - Add wallet-utils.ts with zap request parsing (kind 9734 detection) - Parse zap requests from transaction descriptions with LRU caching (500 entry limit) - Display username + message preview in transaction list with ⚡ indicator - Show full zap details in transaction detail dialog: - Zapper name (clickable UserName component) - Full zap message with RichText formatting - Zapped post rendered inline using KindRenderer - Loading states for event fetching - Follow React Hooks rules (unconditional hook calls) - Type-safe implementation with proper pointer handling Technical notes: - parseZapRequest() extracts sender, message, and event pointers from JSON-embedded zap requests - Caching prevents redundant JSON parsing on re-renders - Supports both event IDs (e tag) and address coordinates (a tag) - parseAddressCoordinate() handles kind:pubkey:identifier format * refactor: use applesauce caching pattern and RichText for zaps Improvements: - Replace Map-based cache with getOrComputeCachedValue pattern - Cache parsed zap requests on transaction objects using Symbol - Follows applesauce convention for computed value caching - More memory-efficient than global Map cache - Use RichText component for zap messages in transaction list - Supports links, mentions, and other rich formatting - CSS truncation instead of JS string manipulation - Update parseZapRequest to accept transaction object instead of description string - Enables proper caching on the transaction object - Cleaner API surface Technical changes: - Remove getZapMessagePreview() helper (now using CSS truncate) - Add getOrComputeCachedValue import from applesauce-core/helpers - Update all parseZapRequest call sites to pass transaction object - Wrap zap message in RichText component for proper formatting * feat: enhance zap display with RichText context and scrollable details Improvements: - Pass zap request event as context to RichText components - Enables proper mention/link resolution in zap messages - Supports interactive elements (mentions, hashtags, links) - Provides full event context for rendering - Make transaction detail dialog scrollable - Add max-h-[90vh] to DialogContent with flex layout - Wrap content in overflow-y-auto container with max-h-[calc(90vh-8rem)] - Prevents dialog overflow when displaying large zapped posts - Smooth scrolling for long zap message threads - Reduce transaction page size from 20 to 10 - Better performance with rich zap rendering - Faster initial load and scroll rendering - Reduces memory footprint for transaction list Technical changes: - Add zapRequestEvent field to ZapRequestInfo interface - Pass zapRequestEvent to all RichText components rendering zap messages - Update BATCH_SIZE constant from 20 to 10 - Add flex layout to DialogContent for proper scrolling - Add pr-2 padding to scrollable container for visual spacing * revert: restore BATCH_SIZE to 20 transactions * refactor: improve zap transaction list item UI Changes: - Use UserName component for zapper display (applies accent color) - Remove colon separator between username and message - Keep username and message on single line with proper truncation - Remove unused imports (getDisplayName, useProfile) - Reduce transaction detail dialog max height from 90vh to 70vh - More compact display for better UX - Prevents excessive white space UI improvements: - Zap icon + UserName (accent color) + message all on one line - UserName component is flex-shrink-0 to prevent squishing - Message text truncates with CSS overflow - Cleaner, more compact visual hierarchy * fix: improve spacing and truncation in zap transaction items - Increase gap between username and message from gap-1 to gap-2 (0.5rem) - Add min-w-0 to message span for proper ellipsis truncation in flex - Remove duplicate truncate class from parent div to prevent conflicts - Message now properly shows ellipsis (...) when it doesn't fit on one line * feat: add line-clamp and expandable raw transaction view Changes: - Replace truncate with line-clamp-1 on zap message for proper single-line clamping - Add expandable 'Show Raw Transaction' section in transaction detail dialog - Collapsible with ChevronRight/ChevronDown icons - Shows JSON.stringify(transaction, null, 2) in scrollable pre block - Uses CodeCopyButton component for consistent copy UX - Max height 60 (15rem) with overflow-y-auto for long transactions - Add state management for raw transaction expansion and copy status - Reset raw transaction state when dialog closes UI improvements: - Clean expansion interaction with hover effects - Properly formatted JSON with 2-space indentation - Accessible copy button with aria-label - Auto-collapses when closing the dialog * feat: parse zap requests from invoice description as fallback Enhance zap request parsing to check multiple sources: - First try transaction.description (primary source) - If not found, decode the Lightning invoice and check its description field - This handles cases where the zap request is embedded in the invoice Changes: - Extract parsing logic into tryParseZapRequestJson() helper - Add invoice field to parseZapRequest() transaction parameter - Import light-bolt11-decoder to decode invoices - Try invoice description as fallback when tx description doesn't contain zap - Maintain applesauce caching pattern on transaction object This ensures zap payments are detected and displayed correctly regardless of where the zap request JSON is stored (tx description vs invoice description). --------- Co-authored-by: Claude --- src/components/WalletViewer.tsx | 320 +++++++++++++++++++++++++------- src/lib/wallet-utils.ts | 121 ++++++++++++ 2 files changed, 377 insertions(+), 64 deletions(-) create mode 100644 src/lib/wallet-utils.ts diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx index 091c201..bd988a2 100644 --- a/src/components/WalletViewer.tsx +++ b/src/components/WalletViewer.tsx @@ -19,6 +19,7 @@ import { ArrowDownLeft, LogOut, ChevronDown, + ChevronRight, } from "lucide-react"; import { Virtuoso } from "react-virtuoso"; import { useWallet } from "@/hooks/useWallet"; @@ -49,6 +50,13 @@ import { } from "@/components/ui/tooltip"; import ConnectWalletDialog from "./ConnectWalletDialog"; import { RelayLink } from "@/components/nostr/RelayLink"; +import { parseZapRequest } from "@/lib/wallet-utils"; +import { Zap } from "lucide-react"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { KindRenderer } from "./nostr/kinds"; +import { RichText } from "./nostr/RichText"; +import { UserName } from "./nostr/UserName"; +import { CodeCopyButton } from "./CodeCopyButton"; interface Transaction { type: "incoming" | "outgoing"; @@ -206,6 +214,132 @@ function parseInvoice(invoice: string): InvoiceDetails | null { } } +/** + * Helper to parse coordinate string (kind:pubkey:identifier) + */ +function parseAddressCoordinate( + coordinate: string, +): { kind: number; pubkey: string; identifier: string } | null { + const parts = coordinate.split(":"); + if (parts.length !== 3) return null; + + const kind = parseInt(parts[0], 10); + if (isNaN(kind)) return null; + + return { + kind, + pubkey: parts[1], + identifier: parts[2], + }; +} + +/** + * Component to render zap details in the transaction detail dialog + */ +function ZapTransactionDetail({ transaction }: { transaction: Transaction }) { + const zapInfo = parseZapRequest(transaction); + + // Parse address coordinate if present (format: kind:pubkey:identifier) + const addressPointer = zapInfo?.zappedEventAddress + ? parseAddressCoordinate(zapInfo.zappedEventAddress) + : null; + + // Call hooks unconditionally (before early return) + const zappedEvent = useNostrEvent( + zapInfo?.zappedEventId + ? { id: zapInfo.zappedEventId } + : addressPointer || undefined, + ); + + // Early return after hooks + if (!zapInfo) return null; + + return ( +
+ {/* Zap sender */} +
+ +
+ +
+
+ + {/* Zap message */} + {zapInfo.message && ( +
+ +
+ +
+
+ )} + + {/* Zapped event */} + {zappedEvent && ( +
+ +
+ +
+
+ )} + + {/* Loading state for zapped event */} + {(zapInfo.zappedEventId || zapInfo.zappedEventAddress) && + !zappedEvent && ( +
+ +
+ Loading event... +
+
+ )} +
+ ); +} + +/** + * Component to render a transaction row with zap detection + */ +function TransactionLabel({ transaction }: { transaction: Transaction }) { + const zapInfo = parseZapRequest(transaction); + + // Not a zap - use original description or default label + if (!zapInfo) { + return ( + + {transaction.description || + (transaction.type === "incoming" ? "Received" : "Payment")} + + ); + } + + // It's a zap! Show username + message on one line + + return ( +
+ +
+ + {zapInfo.message && ( + + + + )} +
+
+ ); +} + export default function WalletViewer() { const { state, disconnectNWC: disconnectNWCFromState } = useGrimoire(); const { @@ -262,6 +396,8 @@ export default function WalletViewer() { const [selectedTransaction, setSelectedTransaction] = useState(null); const [detailDialogOpen, setDetailDialogOpen] = useState(false); + const [showRawTransaction, setShowRawTransaction] = useState(false); + const [copiedRawTx, setCopiedRawTx] = useState(false); // Load wallet info when connected useEffect(() => { @@ -363,7 +499,7 @@ export default function WalletViewer() { // Reload transactions reloadTransactions(); } - } catch (error) { + } catch { // Ignore errors, will retry } finally { setCheckingPayment(false); @@ -985,9 +1121,6 @@ export default function WalletViewer() { } const tx = item.data; - const txLabel = - tx.description || - (tx.type === "incoming" ? "Received" : "Payment"); return (
)} - {txLabel} +

@@ -1062,90 +1195,149 @@ export default function WalletViewer() { {/* Transaction Detail Dialog */} -

- + { + setDetailDialogOpen(open); + if (!open) { + setShowRawTransaction(false); + setCopiedRawTx(false); + } + }} + > + Transaction Details - {selectedTransaction && ( -
-
- {selectedTransaction.type === "incoming" ? ( - - ) : ( - - )} -
-

- {selectedTransaction.type === "incoming" - ? "Received" - : "Sent"} -

-

- {formatSats(selectedTransaction.amount)} sats -

+
+ {selectedTransaction && ( +
+
+ {selectedTransaction.type === "incoming" ? ( + + ) : ( + + )} +
+

+ {selectedTransaction.type === "incoming" + ? "Received" + : "Sent"} +

+

+ {formatSats(selectedTransaction.amount)} sats +

+
-
-
- {selectedTransaction.description && ( +
+ {selectedTransaction.description && + !parseZapRequest(selectedTransaction) && ( +
+ +

+ {selectedTransaction.description} +

+
+ )} +
-

{selectedTransaction.description}

+

+ {formatFullDate(selectedTransaction.created_at)} +

- )} -
- -

- {formatFullDate(selectedTransaction.created_at)} -

-
+ {selectedTransaction.fees_paid !== undefined && + selectedTransaction.fees_paid > 0 && ( +
+ +

+ {formatSats(selectedTransaction.fees_paid)} sats +

+
+ )} - {selectedTransaction.fees_paid !== undefined && - selectedTransaction.fees_paid > 0 && ( + {selectedTransaction.payment_hash && (
-

- {formatSats(selectedTransaction.fees_paid)} sats +

+ {selectedTransaction.payment_hash}

)} - {selectedTransaction.payment_hash && ( -
- -

- {selectedTransaction.payment_hash} -

-
- )} + {selectedTransaction.preimage && ( +
+ +

+ {selectedTransaction.preimage} +

+
+ )} +
- {selectedTransaction.preimage && ( -
- -

- {selectedTransaction.preimage} -

-
- )} + {/* Zap Details (if this is a zap payment) */} + + + {/* Raw Transaction (expandable) */} +
+ + + {showRawTransaction && ( +
+
+
+                          {JSON.stringify(selectedTransaction, null, 2)}
+                        
+ { + navigator.clipboard.writeText( + JSON.stringify(selectedTransaction, null, 2), + ); + setCopiedRawTx(true); + setTimeout(() => setCopiedRawTx(false), 2000); + }} + label="Copy transaction JSON" + /> +
+
+ )} +
-
- )} + )} +
diff --git a/src/lib/wallet-utils.ts b/src/lib/wallet-utils.ts new file mode 100644 index 0000000..8873e24 --- /dev/null +++ b/src/lib/wallet-utils.ts @@ -0,0 +1,121 @@ +/** + * Wallet Utilities + * + * Helper functions for working with wallet transactions and zap payments + */ + +import { NostrEvent } from "@/types/nostr"; +import { getOrComputeCachedValue } from "applesauce-core/helpers"; +import { decode as decodeBolt11 } from "light-bolt11-decoder"; + +export interface ZapRequestInfo { + sender: string; // pubkey of the zapper + message: string; // zap message content + zappedEventId?: string; // ID of the zapped event (from e tag) + zappedEventAddress?: string; // Address of the zapped event (from a tag) + amount?: number; // amount in sats (if available) + zapRequestEvent: NostrEvent; // The full kind 9734 zap request event +} + +// Symbol for caching parsed zap requests on transaction objects +const ZapRequestSymbol = Symbol("zapRequest"); + +/** + * Try to parse a zap request JSON string into a ZapRequestInfo object + * @param jsonString - The JSON string to parse + * @returns ZapRequestInfo if valid zap request, null otherwise + */ +function tryParseZapRequestJson(jsonString: string): ZapRequestInfo | null { + try { + // Try to parse as JSON + const parsed = JSON.parse(jsonString); + + // Check if it's a valid zap request (kind 9734) + if ( + !parsed || + typeof parsed !== "object" || + parsed.kind !== 9734 || + !parsed.pubkey || + typeof parsed.pubkey !== "string" + ) { + return null; + } + + const event = parsed as NostrEvent; + + // Extract zapped event from tags + let zappedEventId: string | undefined; + let zappedEventAddress: string | undefined; + + if (Array.isArray(event.tags)) { + // Look for e tag (event ID) + const eTag = event.tags.find( + (tag) => Array.isArray(tag) && tag.length >= 2 && tag[0] === "e", + ); + if (eTag && typeof eTag[1] === "string") { + zappedEventId = eTag[1]; + } + + // Look for a tag (address/coordinate) + const aTag = event.tags.find( + (tag) => Array.isArray(tag) && tag.length >= 2 && tag[0] === "a", + ); + if (aTag && typeof aTag[1] === "string") { + zappedEventAddress = aTag[1]; + } + } + + return { + sender: event.pubkey, + message: event.content || "", + zappedEventId, + zappedEventAddress, + zapRequestEvent: event, + }; + } catch { + // Not JSON or parsing failed - not a zap request + return null; + } +} + +/** + * Try to parse a zap request from a transaction + * Checks both the transaction description and the invoice description + * Transaction descriptions for zaps contain a JSON-stringified kind 9734 event + * Results are cached on the transaction object using applesauce pattern + * + * @param transaction - The transaction object with description and/or invoice field + * @returns ZapRequestInfo if this is a zap payment, null otherwise + */ +export function parseZapRequest(transaction: { + description?: string; + invoice?: string; +}): ZapRequestInfo | null { + // Use applesauce caching pattern - cache result on transaction object + return getOrComputeCachedValue(transaction, ZapRequestSymbol, () => { + // Try parsing the transaction description first + if (transaction.description) { + const result = tryParseZapRequestJson(transaction.description); + if (result) return result; + } + + // If that didn't work, try decoding the invoice and checking its description + if (transaction.invoice) { + try { + const decoded = decodeBolt11(transaction.invoice); + const descSection = decoded.sections.find( + (s) => s.name === "description", + ); + + if (descSection && descSection.value) { + const result = tryParseZapRequestJson(descSection.value as string); + if (result) return result; + } + } catch { + // Invoice decoding failed, ignore + } + } + + return null; + }); +}