From ea6aeda368dd08f293d47c2ea428ed4451466643 Mon Sep 17 00:00:00 2001 From: mkfain Date: Thu, 28 May 2026 20:44:11 +0200 Subject: [PATCH] Render Bitcoin tx and address pages at /i/bitcoin:tx:* and /i/bitcoin:address:* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP-73 external content page recognized bitcoin:tx: and bitcoin:address: identifiers (parsed and titled correctly), but ExternalContentPage never rendered a body for them — visitors arriving from a wallet transaction row just saw the 'Bitcoin Transaction' header with nothing beneath it. Port BitcoinTxHeader and BitcoinAddressHeader from Ditto: confirmed/ unconfirmed status, block/size/fee/amount stats, mempool.space-style inputs-to-outputs flow, address balance hero with sats + USD, recent-transaction list, and a footer link out to mempool.space. The backing useBitcoinTx and useBitcoinAddress hooks compose Agora's existing fetchTxDetail / fetchAddressData / fetchAddressTxs helpers against the configured esploraApis from AppContext, and share the spot price with useBtcPrice so the page doesn't double-fetch. --- src/components/BitcoinContentHeader.tsx | 641 ++++++++++++++++++++++++ src/hooks/useBitcoinAddress.ts | 65 +++ src/hooks/useBitcoinTx.ts | 25 + src/lib/bitcoin.ts | 8 +- src/pages/ExternalContentPage.tsx | 6 + 5 files changed, 741 insertions(+), 4 deletions(-) create mode 100644 src/components/BitcoinContentHeader.tsx create mode 100644 src/hooks/useBitcoinAddress.ts create mode 100644 src/hooks/useBitcoinTx.ts diff --git a/src/components/BitcoinContentHeader.tsx b/src/components/BitcoinContentHeader.tsx new file mode 100644 index 00000000..b03efef7 --- /dev/null +++ b/src/components/BitcoinContentHeader.tsx @@ -0,0 +1,641 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + ArrowDownLeft, + ArrowRight, + ArrowUpRight, + Bitcoin, + Check, + Clock, + Copy, + ExternalLink, + Hash, + Layers, + RefreshCw, + Weight, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useBitcoinTx } from '@/hooks/useBitcoinTx'; +import { useBitcoinAddress } from '@/hooks/useBitcoinAddress'; +import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin'; +import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin'; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function truncateMiddle(str: string, startLen = 8, endLen = 8): string { + if (str.length <= startLen + endLen + 3) return str; + return `${str.slice(0, startLen)}...${str.slice(-endLen)}`; +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard not available + } + }; + + return ( + + ); +} + +/** Format a unix timestamp as a readable date string. */ +function formatBlockTime(timestamp: number): string { + const date = new Date(timestamp * 1000); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); +} + +/** Format a large number with locale separators. */ +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +// --------------------------------------------------------------------------- +// Bitcoin Transaction Header +// --------------------------------------------------------------------------- + +export function BitcoinTxHeader({ txid }: { txid: string }) { + const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid); + + if (isLoading) return ; + + if (error || !tx) { + return ( +
+ +

Failed to load transaction

+

{txid}

+ + + View on mempool.space + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ {tx.confirmed ? : } +
+
+

+ {tx.confirmed ? 'Confirmed' : 'Unconfirmed'} +

+ {tx.blockTime && ( +

{formatBlockTime(tx.blockTime)}

+ )} +
+
+ + {/* Transaction ID */} +
+

Transaction ID

+
+

{tx.txid}

+ +
+
+ + {/* Stats grid */} +
+ {tx.confirmed && tx.blockHeight !== undefined && ( + } label="Block" value={formatNumber(tx.blockHeight)} /> + )} + } label="Size" value={`${formatNumber(tx.weight / 4)} vB`} /> + } + label="Fee" + value={`${formatSats(tx.fee)} sat`} + subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`} + /> + } + label="Amount" + value={`${formatBTC(tx.totalOutput)} BTC`} + subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined} + /> +
+
+ + {/* Inputs → Outputs flow */} +
+ +
+ + {/* Footer: link to mempool.space */} + +
+ ); +} + +function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) { + return ( +
+
+ {icon} + {label} +
+

{value}

+ {subtitle &&

{subtitle}

} +
+ ); +} + +/** Inputs → Outputs visualization, mempool.space-style. */ +function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) { + return ( +
+
+ {tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''} + + {tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''} +
+ +
+ {/* Inputs */} +
+ {tx.inputs.slice(0, 10).map((input, i) => ( + + ))} + {tx.inputs.length > 10 && ( +

+ +{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''} +

+ )} +
+ + {/* Outputs */} +
+ {tx.outputs.slice(0, 10).map((output, i) => ( + + ))} + {tx.outputs.length > 10 && ( +

+ +{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''} +

+ )} +
+
+
+ ); +} + +function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) { + if (input.isCoinbase) { + return ( +
+
+ Coinbase + {formatBTC(input.value)} BTC +
+
+ ); + } + + return ( +
+
+ {input.address ? ( + + {truncateMiddle(input.address, 10, 6)} + + ) : ( + Unknown + )} + {formatBTC(input.value)} BTC +
+ {btcPrice !== undefined && ( +

{satsToUSD(input.value, btcPrice)}

+ )} +
+ ); +} + +function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) { + const isOpReturn = output.scriptpubkeyType === 'op_return'; + + if (isOpReturn) { + return ( +
+ OP_RETURN +
+ ); + } + + return ( +
+
+ {output.address ? ( + + {truncateMiddle(output.address, 10, 6)} + + ) : ( + Unknown + )} + {formatBTC(output.value)} BTC +
+ {btcPrice !== undefined && ( +

{satsToUSD(output.value, btcPrice)}

+ )} +
+ ); +} + +function TxSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ + +
+
+ + + + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Bitcoin Address Header +// --------------------------------------------------------------------------- + +export function BitcoinAddressHeader({ address }: { address: string }) { + const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address); + + if (isLoading) return ; + + if (error || !addressDetail) { + return ( +
+ +

Failed to load address

+

{address}

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Bitcoin Address

+

+ {addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''} +

+
+
+ + {/* Address */} +
+

Address

+
+

{address}

+ +
+
+ + {/* Balance hero */} +
+

Balance

+

+ {btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`} +

+

+ {formatBTC(addressDetail.totalBalance)} BTC +

+ {addressDetail.pendingBalance !== 0 && ( +

+ + {btcPrice + ? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending` + : `${formatBTC(addressDetail.pendingBalance)} BTC pending`} +

+ )} +
+ + {/* Stats grid */} +
+ } + label="Total Received" + value={`${formatBTC(addressDetail.totalReceived)} BTC`} + subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined} + /> + } + label="Total Sent" + value={`${formatBTC(addressDetail.totalSent)} BTC`} + subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined} + /> +
+
+ + {/* Recent Transactions */} + {addressDetail.recentTxs.length > 0 && ( +
+
+

+ Recent Transactions +

+
+
+ {addressDetail.recentTxs.slice(0, 10).map((tx) => ( + + ))} +
+ {addressDetail.recentTxs.length > 10 && ( +
+

+ {addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''} +

+
+ )} +
+ )} + + {/* Footer: link to mempool.space */} + +
+ ); +} + +function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) { + const isReceive = tx.type === 'receive'; + + return ( + +
+
+ {isReceive ? : } +
+
+

{isReceive ? 'Received' : 'Sent'}

+

{truncateMiddle(tx.txid, 8, 8)}

+
+
+
+

+ {isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC +

+ {btcPrice && ( +

+ {satsToUSD(tx.amount, btcPrice)} +

+ )} +
+ + ); +} + +function AddressSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ + +
+
+ + + +
+
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Compact previews (used in NoteCard embeds, hover cards, etc.) +// --------------------------------------------------------------------------- + +/** Compact preview for a Bitcoin transaction — fetches real data. */ +export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) { + const { tx, btcPrice, isLoading } = useBitcoinTx(txid); + + if (isLoading) { + return ( +
+
+ +
+ + +
+
+
+ ); + } + + const amount = tx ? tx.totalOutput : 0; + const fee = tx?.fee ?? 0; + + return ( + +
+ +
+
+
+ + Bitcoin Transaction + {tx && ( + + {tx.confirmed ? 'Confirmed' : 'Unconfirmed'} + + )} +
+

+ {tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)} + {tx && btcPrice ? ( + ({satsToUSD(amount, btcPrice)}) + ) : null} +

+ {tx && ( +

+ Fee {formatSats(fee)} sats + {tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''} +

+ )} +
+ + + ); +} + +/** Compact preview for a Bitcoin address — fetches real data. */ +export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) { + const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address); + + if (isLoading) { + return ( +
+
+ +
+ + +
+
+
+ ); + } + + const balance = addressDetail?.totalBalance ?? 0; + const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0; + + return ( + +
+ +
+
+
+ + Bitcoin Address +
+

+ {addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)} + {addressDetail && btcPrice ? ( + ({satsToUSD(balance, btcPrice)}) + ) : null} +

+ {addressDetail && ( +

+ {txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''} + {' · '} + {truncateMiddle(address, 8, 6)} +

+ )} +
+ + + ); +} diff --git a/src/hooks/useBitcoinAddress.ts b/src/hooks/useBitcoinAddress.ts new file mode 100644 index 00000000..298ea157 --- /dev/null +++ b/src/hooks/useBitcoinAddress.ts @@ -0,0 +1,65 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchAddressData, fetchAddressTxs, type AddressData } from '@/lib/bitcoin'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; + +/** + * A simplified address-relative transaction row used in the + * `BitcoinAddressHeader` recent transactions list. + */ +export interface AddressRecentTx { + txid: string; + /** Absolute satoshi amount of the address-relative net flow. */ + amount: number; + /** Whether this tx was a net receive or send for the address. */ + type: 'receive' | 'send'; + confirmed: boolean; + /** Block time (unix seconds), undefined if unconfirmed. */ + timestamp?: number; +} + +export interface AddressDetail extends AddressData { + address: string; + /** Most recent transactions (up to 25). */ + recentTxs: AddressRecentTx[]; +} + +/** + * Fetch full Bitcoin address details (balance + recent transactions) via the + * configured Esplora API roots, alongside the current BTC/USD price. + */ +export function useBitcoinAddress(address: string) { + const { config } = useAppContext(); + const { esploraApis } = config; + + const { data: addressDetail, isLoading, error, refetch } = useQuery({ + queryKey: ['bitcoin-address-detail', esploraApis, address], + queryFn: async ({ signal }): Promise => { + const [addrData, txs] = await Promise.all([ + fetchAddressData(address, esploraApis, signal), + fetchAddressTxs(address, esploraApis, undefined, signal), + ]); + + const recentTxs: AddressRecentTx[] = txs.slice(0, 25).map((tx) => ({ + txid: tx.txid, + amount: Math.abs(tx.netSats), + type: tx.netSats >= 0 ? 'receive' : 'send', + confirmed: tx.confirmed, + timestamp: tx.blockTime, + })); + + return { + address, + ...addrData, + recentTxs, + }; + }, + enabled: !!address, + refetchInterval: 30_000, + }); + + const { data: btcPrice } = useBtcPrice(); + + return { addressDetail, btcPrice, isLoading, error, refetch }; +} diff --git a/src/hooks/useBitcoinTx.ts b/src/hooks/useBitcoinTx.ts new file mode 100644 index 00000000..142ec2c0 --- /dev/null +++ b/src/hooks/useBitcoinTx.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchTxDetail } from '@/lib/bitcoin'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; + +/** + * Fetch full transaction details for a Bitcoin txid via the configured + * Esplora API roots, alongside the current BTC/USD price for display. + */ +export function useBitcoinTx(txid: string) { + const { config } = useAppContext(); + const { esploraApis } = config; + + const { data: tx, isLoading, error } = useQuery({ + queryKey: ['bitcoin-tx-detail', esploraApis, txid], + queryFn: ({ signal }) => fetchTxDetail(txid, esploraApis, signal), + enabled: !!txid, + staleTime: 60_000, + }); + + const { data: btcPrice } = useBtcPrice(); + + return { tx, btcPrice, isLoading, error }; +} diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index e527d574..b7933bfd 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -153,7 +153,7 @@ export async function fetchAddressData( // --------------------------------------------------------------------------- /** Convert satoshis to a BTC string with up to 8 decimal places. */ -function satsToBTC(sats: number): string { +export function satsToBTC(sats: number): string { return (sats / 100_000_000).toFixed(8); } @@ -269,7 +269,7 @@ export interface Transaction { // --------------------------------------------------------------------------- /** A single input in a full transaction. */ -interface TxInput { +export interface TxInput { txid: string; vout: number; address?: string; @@ -278,7 +278,7 @@ interface TxInput { } /** A single output in a full transaction. */ -interface TxOutput { +export interface TxOutput { address?: string; value: number; scriptpubkeyType: string; @@ -287,7 +287,7 @@ interface TxOutput { } /** Full transaction detail returned by the Esplora API. */ -interface TxDetail { +export interface TxDetail { txid: string; version: number; locktime: number; diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx index 9cce721a..e3b641de 100644 --- a/src/pages/ExternalContentPage.tsx +++ b/src/pages/ExternalContentPage.tsx @@ -23,6 +23,10 @@ import { BookContentHeader, CountryContentHeader, } from '@/components/ExternalContentHeader'; +import { + BitcoinTxHeader, + BitcoinAddressHeader, +} from '@/components/BitcoinContentHeader'; import { parseExternalUri, headerLabel, seoTitle, type ExternalContent } from '@/lib/externalContent'; import { ratingToStars } from '@/lib/bookstr'; import { formatNumber } from '@/lib/formatNumber'; @@ -393,6 +397,8 @@ export function ExternalContentPage() {
{content.type === 'url' && } {content.type === 'isbn' && } + {content.type === 'bitcoin-tx' && } + {content.type === 'bitcoin-address' && } {content.type === 'unknown' && (