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
This commit is contained in:
Claude
2026-01-18 19:28:19 +00:00
parent 3172288ecf
commit fad156caf0
2 changed files with 335 additions and 62 deletions

View File

@@ -49,6 +49,14 @@ import {
} from "@/components/ui/tooltip";
import ConnectWalletDialog from "./ConnectWalletDialog";
import { RelayLink } from "@/components/nostr/RelayLink";
import { parseZapRequest } from "@/lib/wallet-utils";
import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-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";
interface Transaction {
type: "incoming" | "outgoing";
@@ -206,6 +214,139 @@ 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 (
<div className="space-y-4 pt-4 border-t border-border">
{/* Zap sender */}
<div>
<Label className="text-xs text-muted-foreground flex items-center gap-1">
<Zap className="size-3 fill-zap text-zap" />
Zap From
</Label>
<div className="mt-1">
<UserName pubkey={zapInfo.sender} />
</div>
</div>
{/* Zap message */}
{zapInfo.message && (
<div>
<Label className="text-xs text-muted-foreground">Zap Message</Label>
<div className="mt-1 text-sm">
<RichText
content={zapInfo.message}
event={zapInfo.zapRequestEvent}
/>
</div>
</div>
)}
{/* Zapped event */}
{zappedEvent && (
<div>
<Label className="text-xs text-muted-foreground">Zapped Post</Label>
<div className="mt-1 border border-muted rounded-md overflow-hidden">
<KindRenderer event={zappedEvent} />
</div>
</div>
)}
{/* Loading state for zapped event */}
{(zapInfo.zappedEventId || zapInfo.zappedEventAddress) &&
!zappedEvent && (
<div>
<Label className="text-xs text-muted-foreground">Zapped Post</Label>
<div className="mt-1 text-xs text-muted-foreground">
Loading event...
</div>
</div>
)}
</div>
);
}
/**
* Component to render a transaction row with zap detection
*/
function TransactionLabel({ transaction }: { transaction: Transaction }) {
const zapInfo = parseZapRequest(transaction);
// Call hooks unconditionally (before conditional rendering)
const profile = useProfile(zapInfo?.sender);
// Not a zap - use original description or default label
if (!zapInfo) {
return (
<span className="text-sm truncate">
{transaction.description ||
(transaction.type === "incoming" ? "Received" : "Payment")}
</span>
);
}
// It's a zap! Show username + message preview
const displayName = getDisplayName(zapInfo.sender, profile);
return (
<div className="flex items-center gap-2 min-w-0">
<Zap className="size-3.5 flex-shrink-0 fill-zap text-zap" />
<div className="text-sm truncate min-w-0">
<span className="font-medium">{displayName}</span>
{zapInfo.message && (
<>
<span className="text-muted-foreground">: </span>
<span className="inline">
<RichText
content={zapInfo.message}
event={zapInfo.zapRequestEvent}
/>
</span>
</>
)}
</div>
</div>
);
}
export default function WalletViewer() {
const { state, disconnectNWC: disconnectNWCFromState } = useGrimoire();
const {
@@ -363,7 +504,7 @@ export default function WalletViewer() {
// Reload transactions
reloadTransactions();
}
} catch (error) {
} catch {
// Ignore errors, will retry
} finally {
setCheckingPayment(false);
@@ -985,9 +1126,6 @@ export default function WalletViewer() {
}
const tx = item.data;
const txLabel =
tx.description ||
(tx.type === "incoming" ? "Received" : "Payment");
return (
<div
@@ -1001,7 +1139,7 @@ export default function WalletViewer() {
) : (
<ArrowUpRight className="size-4 text-red-500 flex-shrink-0" />
)}
<span className="text-sm truncate">{txLabel}</span>
<TransactionLabel transaction={tx} />
</div>
<div className="flex-shrink-0 ml-4">
<p className="text-sm font-semibold font-mono">
@@ -1063,84 +1201,94 @@ export default function WalletViewer() {
{/* Transaction Detail Dialog */}
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
<DialogContent>
<DialogContent className="max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Transaction Details</DialogTitle>
</DialogHeader>
{selectedTransaction && (
<div className="space-y-4">
<div className="flex items-center gap-3">
{selectedTransaction.type === "incoming" ? (
<ArrowDownLeft className="size-6 text-green-500" />
) : (
<ArrowUpRight className="size-6 text-red-500" />
)}
<div>
<p className="text-lg font-semibold">
{selectedTransaction.type === "incoming"
? "Received"
: "Sent"}
</p>
<p className="text-2xl font-bold font-mono">
{formatSats(selectedTransaction.amount)} sats
</p>
<div className="overflow-y-auto max-h-[calc(90vh-8rem)] pr-2">
{selectedTransaction && (
<div className="space-y-4">
<div className="flex items-center gap-3">
{selectedTransaction.type === "incoming" ? (
<ArrowDownLeft className="size-6 text-green-500" />
) : (
<ArrowUpRight className="size-6 text-red-500" />
)}
<div>
<p className="text-lg font-semibold">
{selectedTransaction.type === "incoming"
? "Received"
: "Sent"}
</p>
<p className="text-2xl font-bold font-mono">
{formatSats(selectedTransaction.amount)} sats
</p>
</div>
</div>
</div>
<div className="space-y-2">
{selectedTransaction.description && (
<div className="space-y-2">
{selectedTransaction.description &&
!parseZapRequest(selectedTransaction) && (
<div>
<Label className="text-xs text-muted-foreground">
Description
</Label>
<p className="text-sm">
{selectedTransaction.description}
</p>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground">
Description
Date
</Label>
<p className="text-sm">{selectedTransaction.description}</p>
<p className="text-sm font-mono">
{formatFullDate(selectedTransaction.created_at)}
</p>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground">Date</Label>
<p className="text-sm font-mono">
{formatFullDate(selectedTransaction.created_at)}
</p>
</div>
{selectedTransaction.fees_paid !== undefined &&
selectedTransaction.fees_paid > 0 && (
<div>
<Label className="text-xs text-muted-foreground">
Fees Paid
</Label>
<p className="text-sm font-mono">
{formatSats(selectedTransaction.fees_paid)} sats
</p>
</div>
)}
{selectedTransaction.fees_paid !== undefined &&
selectedTransaction.fees_paid > 0 && (
{selectedTransaction.payment_hash && (
<div>
<Label className="text-xs text-muted-foreground">
Fees Paid
Payment Hash
</Label>
<p className="text-sm font-mono">
{formatSats(selectedTransaction.fees_paid)} sats
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
{selectedTransaction.payment_hash}
</p>
</div>
)}
{selectedTransaction.payment_hash && (
<div>
<Label className="text-xs text-muted-foreground">
Payment Hash
</Label>
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
{selectedTransaction.payment_hash}
</p>
</div>
)}
{selectedTransaction.preimage && (
<div>
<Label className="text-xs text-muted-foreground">
Preimage
</Label>
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
{selectedTransaction.preimage}
</p>
</div>
)}
</div>
{selectedTransaction.preimage && (
<div>
<Label className="text-xs text-muted-foreground">
Preimage
</Label>
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
{selectedTransaction.preimage}
</p>
</div>
)}
{/* Zap Details (if this is a zap payment) */}
<ZapTransactionDetail transaction={selectedTransaction} />
</div>
</div>
)}
)}
</div>
<DialogFooter>
<Button

125
src/lib/wallet-utils.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* Wallet Utilities
*
* Helper functions for working with wallet transactions and zap payments
*/
import { NostrEvent } from "@/types/nostr";
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)
}
// Cache for parsed zap requests (keyed by description string)
// Use Map with size limit to prevent unbounded growth
const zapRequestCache = new Map<string, ZapRequestInfo | null>();
const MAX_CACHE_SIZE = 500;
/**
* Try to parse a zap request from a transaction description
* Transaction descriptions for zaps contain a JSON-stringified kind 9734 event
* Results are cached to avoid re-parsing the same descriptions
*
* @param description - The transaction description field
* @returns ZapRequestInfo if this is a zap payment, null otherwise
*/
export function parseZapRequest(description?: string): ZapRequestInfo | null {
if (!description) return null;
// Check cache first
if (zapRequestCache.has(description)) {
return zapRequestCache.get(description)!;
}
let result: ZapRequestInfo | null = null;
try {
// Try to parse as JSON
const parsed = JSON.parse(description);
// Check if it's a valid zap request (kind 9734)
if (
!parsed ||
typeof parsed !== "object" ||
parsed.kind !== 9734 ||
!parsed.pubkey ||
typeof parsed.pubkey !== "string"
) {
result = null;
} else {
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];
}
}
result = {
sender: event.pubkey,
message: event.content || "",
zappedEventId,
zappedEventAddress,
};
}
} catch {
// Not JSON or parsing failed - not a zap request
result = null;
}
// Cache the result (with size limit to prevent unbounded growth)
if (zapRequestCache.size >= MAX_CACHE_SIZE) {
// Remove oldest entry (first key in the map)
const firstKey = zapRequestCache.keys().next().value;
if (firstKey) {
zapRequestCache.delete(firstKey);
}
}
zapRequestCache.set(description, result);
return result;
}
/**
* Get a short preview of a zap message for display in lists
* Truncates to maxLength characters and removes line breaks
*
* @param message - The full zap message
* @param maxLength - Maximum length before truncation (default 50)
* @returns Truncated message with ellipsis if needed
*/
export function getZapMessagePreview(
message: string,
maxLength: number = 50,
): string {
if (!message) return "";
// Remove line breaks and extra whitespace
const cleaned = message.replace(/\s+/g, " ").trim();
if (cleaned.length <= maxLength) {
return cleaned;
}
return cleaned.substring(0, maxLength - 1) + "…";
}