feat: detect and display zap payments in NWC wallet viewer (#140)

* 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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-18 21:52:44 +01:00
committed by GitHub
parent 3172288ecf
commit c94203852e
2 changed files with 377 additions and 64 deletions

View File

@@ -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 (
<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);
// 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 on one line
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 min-w-0 flex items-center gap-2">
<UserName pubkey={zapInfo.sender} className="flex-shrink-0" />
{zapInfo.message && (
<span className="line-clamp-1 min-w-0">
<RichText
content={zapInfo.message}
event={zapInfo.zapRequestEvent}
/>
</span>
)}
</div>
</div>
);
}
export default function WalletViewer() {
const { state, disconnectNWC: disconnectNWCFromState } = useGrimoire();
const {
@@ -262,6 +396,8 @@ export default function WalletViewer() {
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(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 (
<div
@@ -1001,7 +1134,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">
@@ -1062,90 +1195,149 @@ export default function WalletViewer() {
</Dialog>
{/* Transaction Detail Dialog */}
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
<DialogContent>
<Dialog
open={detailDialogOpen}
onOpenChange={(open) => {
setDetailDialogOpen(open);
if (!open) {
setShowRawTransaction(false);
setCopiedRawTx(false);
}
}}
>
<DialogContent className="max-h-[70vh] 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(70vh-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} />
{/* Raw Transaction (expandable) */}
<div className="border-t border-border pt-4 mt-4">
<button
onClick={() => setShowRawTransaction(!showRawTransaction)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
{showRawTransaction ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
<span>Show Raw Transaction</span>
</button>
{showRawTransaction && (
<div className="mt-3 space-y-2">
<div className="relative">
<pre className="text-xs font-mono bg-muted p-3 rounded overflow-x-auto max-h-60 overflow-y-auto">
{JSON.stringify(selectedTransaction, null, 2)}
</pre>
<CodeCopyButton
copied={copiedRawTx}
onCopy={() => {
navigator.clipboard.writeText(
JSON.stringify(selectedTransaction, null, 2),
);
setCopiedRawTx(true);
setTimeout(() => setCopiedRawTx(false), 2000);
}}
label="Copy transaction JSON"
/>
</div>
</div>
)}
</div>
</div>
</div>
)}
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDetailDialogOpen(false)}
onClick={() => {
setDetailDialogOpen(false);
setShowRawTransaction(false);
setCopiedRawTx(false);
}}
>
Close
</Button>

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

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