Files
eranos/src/lib/externalContent.ts
T
Alex Gleason e74cd1efbb Add NIP-73 Bitcoin transaction and address detail pages
Integrate Bitcoin content into the /i/* external content system using NIP-73
identifiers (bitcoin:tx:{txid} and bitcoin:address:{address}).

- Add bitcoin-tx and bitcoin-address types to ExternalContent parser
- Create BitcoinTxHeader with mempool.space-style inputs/outputs flow view
- Create BitcoinAddressHeader with balance, stats, and recent transactions
- Add useBitcoinTx and useBitcoinAddress hooks (mempool.space Esplora API)
- Switch all Bitcoin API calls from blockstream.info to mempool.space
- Update WalletPage to link transactions to /i/bitcoin:tx:{txid} pages
- Remove unused blockExplorerAddress/blockExplorerTx config fields
- Add compact Bitcoin previews for embedded note contexts
2026-04-06 20:58:17 -05:00

143 lines
5.0 KiB
TypeScript

import { embedLabel } from '@/lib/linkEmbed';
import { getCountryInfo } from '@/lib/countries';
// ---------------------------------------------------------------------------
// Types & helpers
// ---------------------------------------------------------------------------
/** Parsed external content identifier with its type. */
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. */
export function parseExternalUri(uri: string): ExternalContent {
if (uri.startsWith('isbn:')) {
return { type: 'isbn', value: uri };
}
if (uri.startsWith('iso3166:')) {
const code = uri.slice('iso3166:'.length);
return { type: 'iso3166', value: uri, code };
}
// NIP-73 Bitcoin transaction: bitcoin:tx:<txid>
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:<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 };
}
return { type: 'unknown', value: uri };
}
/** Format an ISBN with hyphens for display (simplified). */
export function formatIsbn(isbn: string): string {
const digits = isbn.replace(/\D/g, '');
if (digits.length === 13) {
return `${digits.slice(0, 3)}-${digits.slice(3, 4)}-${digits.slice(4, 9)}-${digits.slice(9, 12)}-${digits.slice(12)}`;
}
if (digits.length === 10) {
return `${digits.slice(0, 1)}-${digits.slice(1, 5)}-${digits.slice(5, 9)}-${digits.slice(9)}`;
}
return isbn;
}
/**
* Try to extract a human-readable title from the last meaningful segment of a
* URL path. Returns `null` when the slug looks like an opaque ID rather than
* a readable name (e.g. YouTube video IDs, hex hashes, etc.).
*/
function slugTitle(url: string): string | null {
try {
const { pathname } = new URL(url);
// Walk segments from right to left and pick the first "readable" one
const segments = pathname.split('/').filter(Boolean);
for (let i = segments.length - 1; i >= 0; i--) {
const seg = decodeURIComponent(segments[i]);
// Skip very short segments or ones that look like opaque IDs
if (seg.length < 4) continue;
if (/^[A-Za-z0-9_-]{6,14}$/.test(seg) && !/[- ]/.test(seg)) continue; // likely an ID
// Must contain at least one separator (hyphen or underscore) to look like a slug
if (!/[-_]/.test(seg)) continue;
return seg
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
.trim();
}
} catch {
// invalid URL
}
return null;
}
/** Get a short label for the content type. */
export function headerLabel(content: ExternalContent): string {
switch (content.type) {
case 'url': {
// Prefer a human-readable slug from the URL path
const slug = slugTitle(content.value);
if (slug) return slug;
// Fall back to known embed site name, then hostname
const label = embedLabel(content.value);
if (label) return label;
try {
return new URL(content.value).hostname.replace(/^www\./, '');
} catch {
return 'Web Page';
}
}
case 'isbn':
return 'Books';
case 'iso3166': {
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';
}
}
/** Get a page title for SEO. */
export function seoTitle(content: ExternalContent, appName: string): string {
switch (content.type) {
case 'url':
try {
return `${new URL(content.value).hostname.replace(/^www\./, '')} | ${appName}`;
} catch {
return `Web Page | ${appName}`;
}
case 'isbn': {
const isbn = content.value.replace('isbn:', '');
return `Book (ISBN ${isbn}) | ${appName}`;
}
case 'iso3166': {
const seoInfo = getCountryInfo(content.code);
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}`;
}
}