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; + }); +}