diff --git a/WALLET.md b/WALLET.md index a50641f2..0113ee7b 100644 --- a/WALLET.md +++ b/WALLET.md @@ -89,15 +89,26 @@ import * as ecc from '@bitcoinerlab/secp256k1'; bitcoin.initEccLib(ecc); ``` -## Balance API +## Balance & Transaction APIs -Balance data is fetched from the public Blockstream Esplora API: +All Bitcoin data is fetched from the public [mempool.space](https://mempool.space) Esplora-compatible API: -``` -GET https://blockstream.info/api/address/{address} -``` +| Endpoint | Purpose | +|---|---| +| `GET https://mempool.space/api/address/{address}` | Balance stats (funded/spent sums, tx counts) | +| `GET https://mempool.space/api/address/{address}/txs` | Transaction history for an address | +| `GET https://mempool.space/api/tx/{txid}` | Full transaction detail (inputs, outputs, fee, block) | -Returns confirmed and mempool stats (funded/spent sums, transaction counts). The wallet page polls this endpoint every 30 seconds. +The wallet page polls balance and transaction data every 30 seconds. BTC/USD price is fetched from CoinGecko every 60 seconds. + +## NIP-73 Integration + +Transaction and address detail pages use [NIP-73](https://github.com/nostr-protocol/nips/blob/master/73.md) external content identifiers, enabling Nostr comments and reactions on Bitcoin transactions and addresses: + +- **Transaction pages**: `/i/bitcoin:tx:{txid}` -- renders a mempool.space-style transaction view with inputs, outputs, fee, block info, and USD values +- **Address pages**: `/i/bitcoin:address:{address}` -- renders balance, recent transactions, and total received/sent + +These pages are part of the existing `/i/*` external content system, which also supports URLs, ISBNs, country codes, and other NIP-73 identifier types. ## Security Considerations diff --git a/src/App.tsx b/src/App.tsx index 0299ec05..1ef943aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -149,8 +149,6 @@ const hardcodedConfig: AppConfig = { plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "", savedFeeds: [], imageQuality: 'compressed', - blockExplorerAddress: 'https://mempool.space/address/{address}', - blockExplorerTx: 'https://mempool.space/tx/{txid}', }; /** diff --git a/src/components/BitcoinContentHeader.tsx b/src/components/BitcoinContentHeader.tsx new file mode 100644 index 00000000..790b54b5 --- /dev/null +++ b/src/components/BitcoinContentHeader.tsx @@ -0,0 +1,567 @@ +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 } 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 BTC amount, stripping trailing zeros. */ +function formatBTC(sats: number): string { + return satsToBTC(sats).replace(/\.?0+$/, ''); +} + +/** 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}

+
+ ); + } + + 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 */} +
+ + + View on 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 */} +
+ + + View on 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, etc.) +// --------------------------------------------------------------------------- + +/** Compact preview for a Bitcoin transaction (used in ExternalContentPreview). */ +export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) { + return ( + +
+ +
+
+
+ + Bitcoin Transaction +
+

{truncateMiddle(txid, 12, 8)}

+
+ + + ); +} + +/** Compact preview for a Bitcoin address (used in ExternalContentPreview). */ +export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) { + return ( + +
+ +
+
+
+ + Bitcoin Address +
+

{truncateMiddle(address, 12, 8)}

+
+ + + ); +} diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index a22a70c6..386bd814 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -11,6 +11,7 @@ import { LinkEmbed } from '@/components/LinkEmbed'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; import { BlueskyIcon } from '@/components/icons/BlueskyIcon'; +import { BitcoinTxPreview, BitcoinAddressPreview } from '@/components/BitcoinContentHeader'; import { extractYouTubeId, extractWikipediaTitle, extractBlueskyPost } from '@/lib/linkEmbed'; import { parseExternalUri, formatIsbn } from '@/lib/externalContent'; import { shareOrCopy } from '@/lib/share'; @@ -746,6 +747,10 @@ export function ExternalContentPreview({ identifier }: { identifier: string }) { return ; case 'iso3166': return ; + case 'bitcoin-tx': + return ; + case 'bitcoin-address': + return ; default: return ( diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index a7eca7dd..492244ae 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -241,10 +241,6 @@ export interface AppConfig { savedFeeds: SavedFeed[]; /** Image upload quality: "compressed" resizes/optimizes, "original" uploads as-is. Default: "compressed". */ imageQuality: 'compressed' | 'original'; - /** Block explorer URI template for address pages. Supports RFC 6570 variable: {address}. */ - blockExplorerAddress: string; - /** Block explorer URI template for transaction pages. Supports RFC 6570 variable: {txid}. */ - blockExplorerTx: string; } export interface AppContextType { diff --git a/src/hooks/useBitcoinAddress.ts b/src/hooks/useBitcoinAddress.ts new file mode 100644 index 00000000..56830f40 --- /dev/null +++ b/src/hooks/useBitcoinAddress.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchAddressDetail, fetchBtcPrice } from '@/lib/bitcoin'; + +/** + * Fetch full address details (balance + recent txs) via the mempool.space API. + * Also fetches the current BTC/USD price for display. + */ +export function useBitcoinAddress(address: string) { + const { data: addressDetail, isLoading, error, refetch } = useQuery({ + queryKey: ['bitcoin-address-detail', address], + queryFn: () => fetchAddressDetail(address), + enabled: !!address, + refetchInterval: 30_000, + }); + + const { data: btcPrice } = useQuery({ + queryKey: ['btc-price'], + queryFn: fetchBtcPrice, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + return { addressDetail, btcPrice, isLoading, error, refetch }; +} diff --git a/src/hooks/useBitcoinTx.ts b/src/hooks/useBitcoinTx.ts new file mode 100644 index 00000000..6063e423 --- /dev/null +++ b/src/hooks/useBitcoinTx.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchTxDetail, fetchBtcPrice } from '@/lib/bitcoin'; + +/** + * Fetch full transaction details for a Bitcoin txid via the mempool.space API. + * Also fetches the current BTC/USD price for display. + */ +export function useBitcoinTx(txid: string) { + const { data: tx, isLoading, error } = useQuery({ + queryKey: ['bitcoin-tx-detail', txid], + queryFn: () => fetchTxDetail(txid), + enabled: !!txid, + staleTime: 60_000, + }); + + const { data: btcPrice } = useQuery({ + queryKey: ['btc-price'], + queryFn: fetchBtcPrice, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + return { tx, btcPrice, isLoading, error }; +} diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index 1f2d70cf..c24aa2a6 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -1,5 +1,12 @@ import * as bitcoin from 'bitcoinjs-lib'; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Base URL for the mempool.space Esplora-compatible REST API. */ +const MEMPOOL_API = 'https://mempool.space/api'; + /** * Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address. * @@ -23,7 +30,11 @@ export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string { } } -/** Balance data returned by the Blockstream API. */ +// --------------------------------------------------------------------------- +// Balance / Address data (wallet page) +// --------------------------------------------------------------------------- + +/** Balance data returned by the Esplora API. */ export interface AddressData { /** Confirmed on-chain balance in satoshis. */ balance: number; @@ -43,10 +54,10 @@ export interface AddressData { /** * Fetch balance and transaction stats for a Bitcoin address from the - * Blockstream Esplora API. + * mempool.space Esplora API. */ export async function fetchAddressData(address: string): Promise { - const response = await fetch(`https://blockstream.info/api/address/${address}`); + const response = await fetch(`${MEMPOOL_API}/address/${address}`); if (!response.ok) { throw new Error('Failed to fetch balance'); @@ -68,6 +79,10 @@ export async function fetchAddressData(address: string): Promise { }; } +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + /** Convert satoshis to a BTC string with up to 8 decimal places. */ export function satsToBTC(sats: number): string { return (sats / 100_000_000).toFixed(8); @@ -103,6 +118,10 @@ export function satsToUSD(sats: number, btcPrice: number): string { }); } +// --------------------------------------------------------------------------- +// Wallet-page transaction list (simplified per-address view) +// --------------------------------------------------------------------------- + /** A simplified transaction relevant to a specific address. */ export interface Transaction { /** Transaction ID (hex). */ @@ -118,11 +137,11 @@ export interface Transaction { } /** - * Fetch transactions for a Bitcoin address from the Blockstream Esplora API. + * Fetch transactions for a Bitcoin address from the mempool.space Esplora API. * Returns simplified transactions with net amount relative to the address. */ export async function fetchTransactions(address: string): Promise { - const response = await fetch(`https://blockstream.info/api/address/${address}/txs`); + const response = await fetch(`${MEMPOOL_API}/address/${address}/txs`); if (!response.ok) { throw new Error('Failed to fetch transactions'); @@ -162,3 +181,133 @@ export async function fetchTransactions(address: string): Promise } satisfies Transaction; }); } + +// --------------------------------------------------------------------------- +// Full transaction detail (NIP-73 /i/bitcoin:tx:... page) +// --------------------------------------------------------------------------- + +/** A single input in a full transaction. */ +export interface TxInput { + txid: string; + vout: number; + address?: string; + value: number; + isCoinbase: boolean; +} + +/** A single output in a full transaction. */ +export interface TxOutput { + address?: string; + value: number; + scriptpubkeyType: string; + /** True if the output has been spent. */ + spent: boolean; +} + +/** Full transaction detail returned by the Esplora API. */ +export interface TxDetail { + txid: string; + version: number; + locktime: number; + size: number; + weight: number; + fee: number; + confirmed: boolean; + blockHeight?: number; + blockHash?: string; + blockTime?: number; + inputs: TxInput[]; + outputs: TxOutput[]; + /** Total value of all inputs (sats). */ + totalInput: number; + /** Total value of all outputs (sats). */ + totalOutput: number; +} + +/** Fetch full transaction details from mempool.space. */ +export async function fetchTxDetail(txid: string): Promise { + const response = await fetch(`${MEMPOOL_API}/tx/${txid}`); + if (!response.ok) throw new Error('Failed to fetch transaction'); + + const tx = await response.json(); + + const vin = tx.vin as Array<{ + txid: string; + vout: number; + prevout: { scriptpubkey_address?: string; value: number } | null; + is_coinbase: boolean; + }>; + const vout = tx.vout as Array<{ + scriptpubkey_address?: string; + value: number; + scriptpubkey_type: string; + }>; + const status = tx.status as { confirmed: boolean; block_height?: number; block_hash?: string; block_time?: number }; + + const inputs: TxInput[] = vin.map((input) => ({ + txid: input.txid, + vout: input.vout, + address: input.prevout?.scriptpubkey_address, + value: input.prevout?.value ?? 0, + isCoinbase: input.is_coinbase, + })); + + const outputs: TxOutput[] = vout.map((output) => ({ + address: output.scriptpubkey_address, + value: output.value, + scriptpubkeyType: output.scriptpubkey_type, + spent: false, // Esplora /tx endpoint doesn't include spending info + })); + + const totalInput = inputs.reduce((sum, i) => sum + i.value, 0); + const totalOutput = outputs.reduce((sum, o) => sum + o.value, 0); + + return { + txid: tx.txid as string, + version: tx.version as number, + locktime: tx.locktime as number, + size: tx.size as number, + weight: tx.weight as number, + fee: tx.fee as number, + confirmed: status.confirmed, + blockHeight: status.block_height, + blockHash: status.block_hash, + blockTime: status.block_time, + inputs, + outputs, + totalInput, + totalOutput, + }; +} + +// --------------------------------------------------------------------------- +// Full address detail (NIP-73 /i/bitcoin:address:... page) +// --------------------------------------------------------------------------- + +/** Full address detail combining balance stats + recent transactions. */ +export interface AddressDetail { + address: string; + balance: number; + pendingBalance: number; + totalBalance: number; + totalReceived: number; + totalSent: number; + txCount: number; + pendingTxCount: number; + /** Most recent transactions (up to 25). */ + recentTxs: Transaction[]; +} + +/** Fetch full address details (balance + recent txs) from mempool.space. */ +export async function fetchAddressDetail(address: string): Promise { + const [addrData, txs] = await Promise.all([ + fetchAddressData(address), + fetchTransactions(address), + ]); + + return { + address, + ...addrData, + recentTxs: txs.slice(0, 25), + }; +} diff --git a/src/lib/externalContent.ts b/src/lib/externalContent.ts index b01db600..17594494 100644 --- a/src/lib/externalContent.ts +++ b/src/lib/externalContent.ts @@ -10,6 +10,8 @@ export type ExternalContent = | { type: 'url'; value: string } | { type: 'isbn'; value: string } | { type: 'iso3166'; value: string; code: string } + | { type: 'bitcoin-tx'; value: string; txid: string } + | { type: 'bitcoin-address'; value: string; address: string } | { type: 'unknown'; value: string }; /** Parse a URI string into a typed external content object. */ @@ -21,6 +23,16 @@ export function parseExternalUri(uri: string): ExternalContent { const code = uri.slice('iso3166:'.length); return { type: 'iso3166', value: uri, code }; } + // NIP-73 Bitcoin transaction: bitcoin:tx: + const btcTxMatch = uri.match(/^bitcoin:tx:([0-9a-f]{64})$/i); + if (btcTxMatch) { + return { type: 'bitcoin-tx', value: uri, txid: btcTxMatch[1].toLowerCase() }; + } + // NIP-73 Bitcoin address: bitcoin:address:
+ const btcAddrMatch = uri.match(/^bitcoin:address:(.+)$/); + if (btcAddrMatch) { + return { type: 'bitcoin-address', value: uri, address: btcAddrMatch[1] }; + } if (uri.startsWith('http://') || uri.startsWith('https://')) { return { type: 'url', value: uri }; } @@ -89,6 +101,10 @@ export function headerLabel(content: ExternalContent): string { const info = getCountryInfo(content.code); return info?.subdivisionName ?? info?.name ?? 'Country'; } + case 'bitcoin-tx': + return 'Bitcoin Transaction'; + case 'bitcoin-address': + return 'Bitcoin Address'; default: return 'External Content'; } @@ -112,6 +128,14 @@ export function seoTitle(content: ExternalContent, appName: string): string { const seoName = seoInfo?.subdivisionName ?? seoInfo?.name; return seoName ? `${seoName} | ${appName}` : `Country | ${appName}`; } + case 'bitcoin-tx': { + const shortTxid = `${content.txid.slice(0, 8)}...${content.txid.slice(-8)}`; + return `Bitcoin TX ${shortTxid} | ${appName}`; + } + case 'bitcoin-address': { + const shortAddr = `${content.address.slice(0, 8)}...${content.address.slice(-6)}`; + return `Bitcoin Address ${shortAddr} | ${appName}`; + } default: return `External Content | ${appName}`; } diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 39a2ad5f..ee034e12 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -245,8 +245,6 @@ export const AppConfigSchema = z.object({ }) ).optional().default([]), imageQuality: z.enum(['compressed', 'original']), - blockExplorerAddress: z.string(), - blockExplorerTx: z.string(), }); // ─── DittoConfigSchema (build-time ditto.json) ─────────────────────── diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx index 1f1c53c9..4bea4f71 100644 --- a/src/pages/ExternalContentPage.tsx +++ b/src/pages/ExternalContentPage.tsx @@ -22,6 +22,7 @@ import { BookContentHeader, CountryContentHeader, } from '@/components/ExternalContentHeader'; +import { BitcoinTxHeader, BitcoinAddressHeader } from '@/components/BitcoinContentHeader'; import { PrecipitationEffect } from '@/components/PrecipitationEffect'; import { parseExternalUri, headerLabel, seoTitle, type ExternalContent } from '@/lib/externalContent'; import { ratingToStars } from '@/lib/bookstr'; @@ -259,6 +260,8 @@ export function ExternalContentPage() { {content.type === 'url' && } {content.type === 'isbn' && } {content.type === 'iso3166' && } + {content.type === 'bitcoin-tx' && } + {content.type === 'bitcoin-address' && } {content.type === 'unknown' && (
diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index a60d5930..c880f93d 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; -import UriTemplate from 'uri-templates'; import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -129,7 +129,7 @@ export function WalletPage() {
{transactions.map((tx) => ( - + ))}
@@ -174,15 +174,12 @@ function formatTxDate(timestamp?: number): string { } /** Single transaction row. */ -function TxRow({ tx, btcPrice, txUrlTemplate }: { tx: Transaction; btcPrice?: number; txUrlTemplate: string }) { +function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) { const isReceive = tx.type === 'receive'; - const txUrl = UriTemplate(txUrlTemplate).fill({ txid: tx.txid }); return ( -
@@ -213,6 +210,6 @@ function TxRow({ tx, btcPrice, txUrlTemplate }: { tx: Transaction; btcPrice?: nu {satsToBTC(tx.amount).replace(/\.?0+$/, '')} BTC

-
+ ); } diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index 46edafcf..2a3f6076 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -111,8 +111,6 @@ export function TestApp({ children }: TestAppProps) { plausibleEndpoint: "", savedFeeds: [], imageQuality: 'compressed', - blockExplorerAddress: 'https://mempool.space/address/{address}', - blockExplorerTx: 'https://mempool.space/tx/{txid}', }; return (