From cf6fcc353cd109500523ed290a771c7d13778406 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:04:17 -0500 Subject: [PATCH 001/330] Add Bitcoin wallet page deriving Taproot address from Nostr pubkey Derive a bc1p... Taproot address directly from the user's Nostr public key (both use secp256k1 x-only keys) and display balance via Blockstream API. Includes QR code, copy-to-clipboard, balance with pending detection, and a WALLET.md documenting the derivation algorithm. Sending is not yet implemented. --- WALLET.md | 114 ++++++++++++++++ package-lock.json | 200 ++++++++++++++++++++++++++- package.json | 4 + src/AppRouter.tsx | 2 + src/hooks/useBitcoinWallet.ts | 47 +++++++ src/lib/bitcoin.ts | 79 +++++++++++ src/lib/polyfills.ts | 9 ++ src/main.tsx | 7 +- src/pages/WalletPage.tsx | 248 ++++++++++++++++++++++++++++++++++ 9 files changed, 707 insertions(+), 3 deletions(-) create mode 100644 WALLET.md create mode 100644 src/hooks/useBitcoinWallet.ts create mode 100644 src/lib/bitcoin.ts create mode 100644 src/pages/WalletPage.tsx diff --git a/WALLET.md b/WALLET.md new file mode 100644 index 00000000..a50641f2 --- /dev/null +++ b/WALLET.md @@ -0,0 +1,114 @@ +# Nostr-to-Bitcoin Wallet + +This document explains how the application derives a Bitcoin Taproot address from a Nostr public key, enabling every Nostr identity to function as a Bitcoin wallet. + +## Why This Works + +Nostr and Bitcoin Taproot (BIP-341) share the exact same cryptographic primitives: + +| Property | Nostr | Bitcoin Taproot | +|---|---|---| +| Curve | secp256k1 | secp256k1 | +| Signature scheme | Schnorr (BIP-340) | Schnorr (BIP-340) | +| Public key format | 32-byte x-only | 32-byte x-only | + +Because the key formats are byte-for-byte identical, a Nostr public key can be used **directly** as a Taproot internal key with no mathematical conversion, hashing, or derivation. + +## Derivation Algorithm + +### Step 1 -- Parse the Public Key + +A Nostr pubkey is a 64-character hex string representing 32 bytes. Convert it to a byte buffer: + +``` +pubkey (hex): e7a2e3b5f1c8d4a6... (64 hex chars = 32 bytes) + ↓ +pubkeyBuffer: +``` + +### Step 2 -- Compute the Taproot Output Key + +Bitcoin Taproot (BIP-341) defines a "tweaking" process for the internal key: + +``` +t = taggedHash("TapTweak", internalPubkey) +Q = P + t*G (where P = internal key, G = generator point) +``` + +When there is no script tree (key-path-only spend), only the internal key participates in the tweak. The result `Q` is the **output key** that appears on-chain. + +This step is handled internally by `bitcoinjs-lib`'s `payments.p2tr()`. + +### Step 3 -- Encode as a bech32m Address + +The 32-byte output key `Q` is encoded with: + +- Witness version: **1** (Taproot) +- Encoding: **bech32m** (BIP-350) +- Human-readable prefix: `bc` (mainnet) + +The resulting address always starts with `bc1p`. + +### Implementation + +```typescript +import * as bitcoin from 'bitcoinjs-lib'; + +function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string { + const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex'); + + const { address } = bitcoin.payments.p2tr({ + internalPubkey: pubkeyBuffer, + network: bitcoin.networks.bitcoin, + }); + + return address; // "bc1p..." +} +``` + +### Example + +``` +Nostr pubkey (hex): 82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2 +Bitcoin address: bc1pw0qkazw9twl4snwxal6v90djv3c8cph4s0w7rvtyp3k95rll3cqqhv4cn8 +``` + +## Dependencies + +| Package | Role | +|---|---| +| `bitcoinjs-lib` | P2TR address generation, PSBT construction | +| `@bitcoinerlab/secp256k1` | secp256k1 ECC operations (Schnorr, key tweaking) | +| `buffer` | Node.js Buffer polyfill for the browser | + +The ECC library must be initialized once at startup: + +```typescript +import * as bitcoin from 'bitcoinjs-lib'; +import * as ecc from '@bitcoinerlab/secp256k1'; +bitcoin.initEccLib(ecc); +``` + +## Balance API + +Balance data is fetched from the public Blockstream Esplora API: + +``` +GET https://blockstream.info/api/address/{address} +``` + +Returns confirmed and mempool stats (funded/spent sums, transaction counts). The wallet page polls this endpoint every 30 seconds. + +## Security Considerations + +- The same private key (nsec in Nostr) controls both the Nostr identity and the Bitcoin funds at the derived address. +- Extension and bunker logins do not expose the raw private key, so spending Bitcoin from those login types requires exporting the key or using a compatible wallet application. +- This is a **single-key** Taproot address with no HD derivation (no BIP-32/BIP-44 path). Every Nostr keypair maps to exactly one Bitcoin address. +- Users should ensure they have secure backups of their Nostr private key before receiving Bitcoin at the derived address. + +## References + +- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) +- [BIP-341: Taproot (SegWit v1)](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) +- [BIP-350: Bech32m](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki) +- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) (defines secp256k1 x-only keys for Nostr) diff --git a/package-lock.json b/package-lock.json index b36c6bdf..e99ca79e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "ditto", - "version": "2.6.0", + "version": "2.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.6.0", + "version": "2.6.1", "dependencies": { + "@bitcoinerlab/secp256k1": "^1.2.0", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", "@capacitor/filesystem": "^8.1.2", @@ -93,6 +94,7 @@ "@tanstack/react-query": "^5.56.2", "@unhead/addons": "^2.0.10", "@unhead/react": "^2.0.10", + "bitcoinjs-lib": "^7.0.1", "blurhash": "^2.0.5", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", @@ -100,6 +102,7 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "dompurify": "^3.3.3", + "ecpair": "^3.0.1", "embla-carousel-react": "^8.3.0", "emoji-mart": "^5.6.0", "fflate": "^0.8.2", @@ -126,6 +129,7 @@ "smol-toml": "^1.6.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "tiny-secp256k1": "^2.2.4", "uri-templates": "^0.2.0", "vaul": "^1.1.2", "zod": "^4.3.6" @@ -286,6 +290,42 @@ "node": ">=6.9.0" } }, + "node_modules/@bitcoinerlab/secp256k1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz", + "integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.7.0" + } + }, + "node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@capacitor/android": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.1.0.tgz", @@ -7419,6 +7459,12 @@ "dev": true, "license": "MIT" }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -7439,6 +7485,12 @@ ], "license": "MIT" }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", + "license": "MIT" + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -7461,6 +7513,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bip174": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz", + "integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==", + "license": "MIT", + "dependencies": { + "uint8array-tools": "^0.0.9", + "varuint-bitcoin": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bitcoinjs-lib": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz", + "integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bech32": "^2.0.0", + "bip174": "^3.0.0", + "bs58check": "^4.0.0", + "uint8array-tools": "^0.0.9", + "valibot": "^1.2.0", + "varuint-bitcoin": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/blurhash": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", @@ -7536,6 +7619,25 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -8314,6 +8416,29 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/ecpair": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz", + "integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==", + "license": "MIT", + "dependencies": { + "uint8array-tools": "^0.0.8", + "valibot": "^1.2.0", + "wif": "^5.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/ecpair/node_modules/uint8array-tools": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz", + "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.149", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz", @@ -13400,6 +13525,27 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tiny-secp256k1": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.4.tgz", + "integrity": "sha512-FoDTcToPqZE454Q04hH9o2EhxWsm7pOSpicyHkgTwKhdKWdsTUuqfP5MLq3g+VjAtl2vSx6JpXGdwA2qpYkI0Q==", + "license": "MIT", + "dependencies": { + "uint8array-tools": "0.0.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tiny-secp256k1/node_modules/uint8array-tools": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", + "integrity": "sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -13659,6 +13805,15 @@ "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "license": "MIT" }, + "node_modules/uint8array-tools": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz", + "integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -13948,6 +14103,38 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/varuint-bitcoin": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz", + "integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==", + "license": "MIT", + "dependencies": { + "uint8array-tools": "^0.0.8" + } + }, + "node_modules/varuint-bitcoin/node_modules/uint8array-tools": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz", + "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", @@ -15541,6 +15728,15 @@ "node": ">=8" } }, + "node_modules/wif": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz", + "integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==", + "license": "MIT", + "dependencies": { + "bs58check": "^4.0.0" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 007f383f..f0f36e80 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "node": ">=22" }, "dependencies": { + "@bitcoinerlab/secp256k1": "^1.2.0", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", "@capacitor/filesystem": "^8.1.2", @@ -99,6 +100,7 @@ "@tanstack/react-query": "^5.56.2", "@unhead/addons": "^2.0.10", "@unhead/react": "^2.0.10", + "bitcoinjs-lib": "^7.0.1", "blurhash": "^2.0.5", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", @@ -106,6 +108,7 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "dompurify": "^3.3.3", + "ecpair": "^3.0.1", "embla-carousel-react": "^8.3.0", "emoji-mart": "^5.6.0", "fflate": "^0.8.2", @@ -132,6 +135,7 @@ "smol-toml": "^1.6.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "tiny-secp256k1": "^2.2.4", "uri-templates": "^0.2.0", "vaul": "^1.1.2", "zod": "^4.3.6" diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 2f9590e0..8075bd86 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -73,6 +73,7 @@ const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage }))); const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage }))); const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage }))); +const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage }))); const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage }))); const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage }))); const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage }))); @@ -255,6 +256,7 @@ export function AppRouter() { } /> } /> + } /> } /> } /> } /> diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts new file mode 100644 index 00000000..dca35eca --- /dev/null +++ b/src/hooks/useBitcoinWallet.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { nostrPubkeyToBitcoinAddress, fetchAddressData } from '@/lib/bitcoin'; + +/** + * Hook that derives a Bitcoin Taproot address from the current user's Nostr + * pubkey and fetches the on-chain balance from the Blockstream API. + * + * Balance auto-refreshes every 30 seconds while the component is mounted. + */ +export function useBitcoinWallet() { + const { user } = useCurrentUser(); + + const bitcoinAddress = useMemo(() => { + if (!user) return ''; + return nostrPubkeyToBitcoinAddress(user.pubkey); + }, [user]); + + const { + data: addressData, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['bitcoin-balance', bitcoinAddress], + queryFn: () => fetchAddressData(bitcoinAddress), + enabled: !!bitcoinAddress, + refetchInterval: 30_000, + }); + + return { + /** The derived bc1p... Taproot address. */ + bitcoinAddress, + /** Balance and transaction data (undefined while loading). */ + addressData, + /** Whether the initial balance fetch is in progress. */ + isLoading, + /** Error from the balance query, if any. */ + error, + /** Manually trigger a balance refresh. */ + refetch, + /** The current user's hex pubkey (convenience). */ + pubkey: user?.pubkey ?? '', + }; +} diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts new file mode 100644 index 00000000..14eca44b --- /dev/null +++ b/src/lib/bitcoin.ts @@ -0,0 +1,79 @@ +import * as bitcoin from 'bitcoinjs-lib'; + +/** + * Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address. + * + * Both Nostr and Bitcoin Taproot use secp256k1 with 32-byte x-only public keys + * (Schnorr / BIP-340), so the key can be used directly as a Taproot internal + * public key with no mathematical conversion. + */ +export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string { + try { + const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex'); + + const { address } = bitcoin.payments.p2tr({ + internalPubkey: pubkeyBuffer, + network: bitcoin.networks.bitcoin, + }); + + return address || ''; + } catch (error) { + console.error('Error generating Bitcoin address:', error); + return ''; + } +} + +/** Balance data returned by the Blockstream API. */ +export interface AddressData { + /** Confirmed on-chain balance in satoshis. */ + balance: number; + /** Unconfirmed mempool balance in satoshis. */ + pendingBalance: number; + /** Sum of confirmed + pending balance. */ + totalBalance: number; + /** Total satoshis ever received (confirmed). */ + totalReceived: number; + /** Total satoshis ever sent (confirmed). */ + totalSent: number; + /** Confirmed transaction count. */ + txCount: number; + /** Pending (mempool) transaction count. */ + pendingTxCount: number; +} + +/** + * Fetch balance and transaction stats for a Bitcoin address from the + * Blockstream Esplora API. + */ +export async function fetchAddressData(address: string): Promise { + const response = await fetch(`https://blockstream.info/api/address/${address}`); + + if (!response.ok) { + throw new Error('Failed to fetch balance'); + } + + const data = await response.json(); + + const confirmedBalance = data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum; + const pendingBalance = data.mempool_stats.funded_txo_sum - data.mempool_stats.spent_txo_sum; + + return { + balance: confirmedBalance, + pendingBalance, + totalBalance: confirmedBalance + pendingBalance, + totalReceived: data.chain_stats.funded_txo_sum, + totalSent: data.chain_stats.spent_txo_sum, + txCount: data.chain_stats.tx_count, + pendingTxCount: data.mempool_stats.tx_count, + }; +} + +/** 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); +} + +/** Format a satoshi amount with locale-aware thousand separators. */ +export function formatSats(sats: number): string { + return sats.toLocaleString(); +} diff --git a/src/lib/polyfills.ts b/src/lib/polyfills.ts index edb726ee..76572d63 100644 --- a/src/lib/polyfills.ts +++ b/src/lib/polyfills.ts @@ -1,3 +1,12 @@ +/** + * Polyfill for Buffer in browser environment. + * + * Many Node.js libraries like bitcoinjs-lib expect Buffer to be globally available. + * This polyfill makes the buffer package's Buffer available globally. + */ +import { Buffer } from 'buffer'; +globalThis.Buffer = Buffer; + /** * Polyfill for AbortSignal.any() * diff --git a/src/main.tsx b/src/main.tsx index 9859c771..488cdbf7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,13 @@ import { createRoot } from 'react-dom/client'; -// Import polyfills first +// Import polyfills first (Buffer must be globally available before bitcoinjs-lib) import './lib/polyfills.ts'; +// Initialize ECC library for bitcoinjs-lib (Taproot / Schnorr support) +import * as bitcoin from 'bitcoinjs-lib'; +import * as ecc from '@bitcoinerlab/secp256k1'; +bitcoin.initEccLib(ecc); + // Kick off cache hydration early so data is ready before components render. import { hydrateNip05Cache } from '@/lib/nip05Cache'; import { hydrateProfileCache } from '@/lib/profileCache'; diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx new file mode 100644 index 00000000..9e0284b4 --- /dev/null +++ b/src/pages/WalletPage.tsx @@ -0,0 +1,248 @@ +import { useState } from 'react'; +import { useSeoMeta } from '@unhead/react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ArrowDownLeft, ArrowUpRight, Hash, ExternalLink } from 'lucide-react'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PageHeader } from '@/components/PageHeader'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; +import { satsToBTC, formatSats } from '@/lib/bitcoin'; + +export function WalletPage() { + const { config } = useAppContext(); + const { user } = useCurrentUser(); + const { bitcoinAddress, addressData, isLoading, error, refetch } = useBitcoinWallet(); + + const [copiedAddress, setCopiedAddress] = useState(false); + + useSeoMeta({ + title: `Bitcoin Wallet | ${config.appName}`, + description: 'Your Bitcoin Taproot wallet derived from your Nostr identity.', + }); + + const copyAddress = async () => { + if (!bitcoinAddress) return; + try { + await navigator.clipboard.writeText(bitcoinAddress); + setCopiedAddress(true); + setTimeout(() => setCopiedAddress(false), 2000); + } catch { + // clipboard API not available + } + }; + + return ( +
+ } /> + + {!user ? ( +
+
+ +
+
+

Your Bitcoin Wallet

+

+ Log in to see your Bitcoin Taproot address derived from your Nostr identity. +

+
+ +
+ ) : ( +
+ {/* Balance Card */} + + +
+ + + Balance + + +
+
+ + {isLoading ? ( +
+ +
+ + + +
+
+ ) : error ? ( +
+

Failed to fetch balance. Please try again.

+ +
+ ) : addressData ? ( + <> + {/* Main balance display */} +
+
+ {satsToBTC(addressData.totalBalance).replace(/\.?0+$/, '')} BTC +
+
+ {formatSats(addressData.totalBalance)} sats +
+ + {addressData.pendingBalance !== 0 && ( +
+ + Confirmed: {satsToBTC(addressData.balance).replace(/\.?0+$/, '')} BTC + + + + Pending: {satsToBTC(addressData.pendingBalance).replace(/\.?0+$/, '')} BTC + +
+ )} +
+ + {/* Stats grid */} +
+
+
+ + Received +
+
{formatSats(addressData.totalReceived)}
+
+
+
+ + Sent +
+
{formatSats(addressData.totalSent)}
+
+
+
+ + Txns +
+
+ {addressData.txCount} + {addressData.pendingTxCount > 0 && ( + (+{addressData.pendingTxCount}) + )} +
+
+
+ + ) : null} +
+
+ + {/* Address Card with QR */} + + + + + Your Bitcoin Address + + + + {/* QR Code */} +
+
+ +
+
+ + {/* Address text */} +
+

+ {bitcoinAddress} +

+
+ + {/* Copy button */} + + + {/* View on explorer */} + + + {/* Warning */} +

+ This is a Taproot (P2TR) address derived from your Nostr key. You need + access to your Nostr private key to spend funds sent here. +

+
+
+ + {/* How it works */} + + + How It Works + + +

+ Same cryptography.{' '} + Both Nostr and Bitcoin Taproot use secp256k1 with Schnorr signatures (BIP-340). + Your 32-byte Nostr x-only public key is byte-for-byte identical to a Taproot + internal key. +

+

+ Direct derivation.{' '} + The bc1p address + above is generated by passing your Nostr pubkey to{' '} + bitcoin.payments.p2tr(){' '} + as the internal key. No seed phrases or HD derivation paths involved. +

+

+ Caution: This is an experimental feature. + Always test with small amounts first and ensure you have secure backups of your + Nostr private key. +

+
+
+
+ )} +
+ ); +} From a75fef039d6c745ec72c15c00a94cb361059cecf Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:06:39 -0500 Subject: [PATCH 002/330] Add Bitcoin Wallet to the left sidebar --- src/lib/sidebarItems.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 29d83766..00ed5aa9 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -31,6 +31,7 @@ import { Settings, Smile, SmilePlus, + Wallet, Sparkles, TrendingUp, User, @@ -134,6 +135,13 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [ requiresAuth: true, }, { id: "settings", label: "Settings", path: "/settings", icon: Settings }, + { + id: "wallet", + label: "Bitcoin Wallet", + path: "/wallet", + icon: Wallet, + requiresAuth: true, + }, { id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText }, { id: "letters", From c8d46b361148e30b80e59823b53d4f1b6747aeee Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:10:49 -0500 Subject: [PATCH 003/330] Rename sidebar label from 'Bitcoin Wallet' to 'Wallet' --- src/lib/sidebarItems.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 00ed5aa9..1e1d36fe 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -137,7 +137,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [ { id: "settings", label: "Settings", path: "/settings", icon: Settings }, { id: "wallet", - label: "Bitcoin Wallet", + label: "Wallet", path: "/wallet", icon: Wallet, requiresAuth: true, From 2c853ff02a4cfe5b26c08cd66b4dfdfa35ddafa5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:13:02 -0500 Subject: [PATCH 004/330] Rename page title and header from 'Bitcoin Wallet' to 'Wallet' --- src/pages/WalletPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index 9e0284b4..e8b42e63 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -21,7 +21,7 @@ export function WalletPage() { const [copiedAddress, setCopiedAddress] = useState(false); useSeoMeta({ - title: `Bitcoin Wallet | ${config.appName}`, + title: `Wallet | ${config.appName}`, description: 'Your Bitcoin Taproot wallet derived from your Nostr identity.', }); @@ -38,7 +38,7 @@ export function WalletPage() { return (
- } /> + } /> {!user ? (
From a145f92bcba835780e7441c5dcbb8c3f663f46a0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:20:05 -0500 Subject: [PATCH 005/330] Simplify wallet page to modern crypto wallet UX Remove outer Balance card wrapper, stats grid, and How It Works section. Balance is now the hero element, centered with QR code below and a compact pill-shaped address with inline copy. Clean, minimal layout. --- src/pages/WalletPage.tsx | 254 +++++++++++---------------------------- 1 file changed, 69 insertions(+), 185 deletions(-) diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index e8b42e63..d0c528a5 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,8 +1,7 @@ import { useState } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Bitcoin, Copy, Check, RefreshCw, Wallet, ArrowDownLeft, ArrowUpRight, Hash, ExternalLink } from 'lucide-react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ExternalLink } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { PageHeader } from '@/components/PageHeader'; @@ -36,6 +35,10 @@ export function WalletPage() { } }; + const truncatedAddress = bitcoinAddress + ? `${bitcoinAddress.slice(0, 12)}...${bitcoinAddress.slice(-8)}` + : ''; + return (
} /> @@ -54,193 +57,74 @@ export function WalletPage() {
) : ( -
- {/* Balance Card */} - - -
- - - Balance - - -
-
- - {isLoading ? ( -
- -
- - - -
-
- ) : error ? ( -
-

Failed to fetch balance. Please try again.

- -
- ) : addressData ? ( - <> - {/* Main balance display */} -
-
- {satsToBTC(addressData.totalBalance).replace(/\.?0+$/, '')} BTC -
-
- {formatSats(addressData.totalBalance)} sats -
- - {addressData.pendingBalance !== 0 && ( -
- - Confirmed: {satsToBTC(addressData.balance).replace(/\.?0+$/, '')} BTC - - - - Pending: {satsToBTC(addressData.pendingBalance).replace(/\.?0+$/, '')} BTC - -
- )} -
- - {/* Stats grid */} -
-
-
- - Received -
-
{formatSats(addressData.totalReceived)}
-
-
-
- - Sent -
-
{formatSats(addressData.totalSent)}
-
-
-
- - Txns -
-
- {addressData.txCount} - {addressData.pendingTxCount > 0 && ( - (+{addressData.pendingTxCount}) - )} -
-
-
- - ) : null} -
-
- - {/* Address Card with QR */} - - - - - Your Bitcoin Address - - - - {/* QR Code */} -
-
- -
-
- - {/* Address text */} -
-

- {bitcoinAddress} -

-
- - {/* Copy button */} - - - {/* View on explorer */} - + + {satsToBTC(addressData.totalBalance).replace(/\.?0+$/, '')} + + BTC + + + {formatSats(addressData.totalBalance)} sats + - {/* Warning */} -

- This is a Taproot (P2TR) address derived from your Nostr key. You need - access to your Nostr private key to spend funds sent here. -

-
-
+ {addressData.pendingBalance !== 0 && ( + + + {formatSats(addressData.pendingBalance)} sats pending + + )} +
+ ) : null} - {/* How it works */} - - - How It Works - - -

- Same cryptography.{' '} - Both Nostr and Bitcoin Taproot use secp256k1 with Schnorr signatures (BIP-340). - Your 32-byte Nostr x-only public key is byte-for-byte identical to a Taproot - internal key. -

-

- Direct derivation.{' '} - The bc1p address - above is generated by passing your Nostr pubkey to{' '} - bitcoin.payments.p2tr(){' '} - as the internal key. No seed phrases or HD derivation paths involved. -

-

- Caution: This is an experimental feature. - Always test with small amounts first and ensure you have secure backups of your - Nostr private key. -

-
-
+ {/* QR Code */} +
+ +
+ + {/* Address + copy */} + + + {/* Explorer link */} + + + View on explorer + )}
From 43917436954ffca1ed4f883ade1f4179c365ba40 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:28:21 -0500 Subject: [PATCH 006/330] Show wallet balance in USD with BTC underneath Fetch BTC/USD price from CoinGecko (refreshes every 60s). Display USD as the hero balance, BTC amount as the secondary line. Remove sats display entirely. Pending amounts also shown in USD. --- src/hooks/useBitcoinWallet.ts | 12 +++++++++++- src/lib/bitcoin.ts | 25 +++++++++++++++++++++++++ src/pages/WalletPage.tsx | 25 +++++++++++-------------- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts index dca35eca..a4bf1562 100644 --- a/src/hooks/useBitcoinWallet.ts +++ b/src/hooks/useBitcoinWallet.ts @@ -2,13 +2,14 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { nostrPubkeyToBitcoinAddress, fetchAddressData } from '@/lib/bitcoin'; +import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice } from '@/lib/bitcoin'; /** * Hook that derives a Bitcoin Taproot address from the current user's Nostr * pubkey and fetches the on-chain balance from the Blockstream API. * * Balance auto-refreshes every 30 seconds while the component is mounted. + * BTC/USD price refreshes every 60 seconds. */ export function useBitcoinWallet() { const { user } = useCurrentUser(); @@ -30,11 +31,20 @@ export function useBitcoinWallet() { refetchInterval: 30_000, }); + const { data: btcPrice } = useQuery({ + queryKey: ['btc-price'], + queryFn: fetchBtcPrice, + refetchInterval: 60_000, + staleTime: 30_000, + }); + return { /** The derived bc1p... Taproot address. */ bitcoinAddress, /** Balance and transaction data (undefined while loading). */ addressData, + /** Current BTC price in USD. */ + btcPrice, /** Whether the initial balance fetch is in progress. */ isLoading, /** Error from the balance query, if any. */ diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index 14eca44b..7c45b679 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -77,3 +77,28 @@ export function satsToBTC(sats: number): string { export function formatSats(sats: number): string { return sats.toLocaleString(); } + +/** Fetch the current BTC price in USD from the CoinGecko API. */ +export async function fetchBtcPrice(): Promise { + const response = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd', + ); + + if (!response.ok) { + throw new Error('Failed to fetch BTC price'); + } + + const data = await response.json(); + return data.bitcoin.usd; +} + +/** Convert satoshis to USD given a BTC price. */ +export function satsToUSD(sats: number, btcPrice: number): string { + const btc = sats / 100_000_000; + return (btc * btcPrice).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index d0c528a5..ea2deb41 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -10,12 +10,12 @@ import { QRCodeCanvas } from '@/components/ui/qrcode'; import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; -import { satsToBTC, formatSats } from '@/lib/bitcoin'; +import { satsToBTC, satsToUSD } from '@/lib/bitcoin'; export function WalletPage() { const { config } = useAppContext(); const { user } = useCurrentUser(); - const { bitcoinAddress, addressData, isLoading, error, refetch } = useBitcoinWallet(); + const { bitcoinAddress, addressData, btcPrice, isLoading, error, refetch } = useBitcoinWallet(); const [copiedAddress, setCopiedAddress] = useState(false); @@ -74,24 +74,21 @@ export function WalletPage() { ) : addressData ? (
- + + {btcPrice + ? satsToUSD(addressData.totalBalance, btcPrice) + : '---'} + - {formatSats(addressData.totalBalance)} sats + {satsToBTC(addressData.totalBalance).replace(/\.?0+$/, '')} BTC {addressData.pendingBalance !== 0 && ( - {formatSats(addressData.pendingBalance)} sats pending + {btcPrice + ? `${satsToUSD(addressData.pendingBalance, btcPrice)} pending` + : 'pending'} )}
From 5ce2d3d8b4ef6284f830d8c0442fe06b5329ce47 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:34:16 -0500 Subject: [PATCH 007/330] Add transaction history to wallet page Fetch transactions from Blockstream Esplora API, compute net amount per tx relative to the user's address, and display as a list below the QR code. Each row shows receive/send direction, relative date, USD amount (with BTC underneath), and links to the block explorer. Includes loading skeletons and empty state. --- src/hooks/useBitcoinWallet.ts | 16 +++++- src/lib/bitcoin.ts | 60 ++++++++++++++++++++ src/pages/WalletPage.tsx | 100 +++++++++++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts index a4bf1562..2d7bf0b1 100644 --- a/src/hooks/useBitcoinWallet.ts +++ b/src/hooks/useBitcoinWallet.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice } from '@/lib/bitcoin'; +import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice, fetchTransactions } from '@/lib/bitcoin'; /** * Hook that derives a Bitcoin Taproot address from the current user's Nostr @@ -38,6 +38,16 @@ export function useBitcoinWallet() { staleTime: 30_000, }); + const { + data: transactions, + isLoading: isLoadingTxs, + } = useQuery({ + queryKey: ['bitcoin-txs', bitcoinAddress], + queryFn: () => fetchTransactions(bitcoinAddress), + enabled: !!bitcoinAddress, + refetchInterval: 30_000, + }); + return { /** The derived bc1p... Taproot address. */ bitcoinAddress, @@ -45,8 +55,12 @@ export function useBitcoinWallet() { addressData, /** Current BTC price in USD. */ btcPrice, + /** Transaction history for the address. */ + transactions, /** Whether the initial balance fetch is in progress. */ isLoading, + /** Whether transactions are still loading. */ + isLoadingTxs, /** Error from the balance query, if any. */ error, /** Manually trigger a balance refresh. */ diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index 7c45b679..1f2d70cf 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -102,3 +102,63 @@ export function satsToUSD(sats: number, btcPrice: number): string { maximumFractionDigits: 2, }); } + +/** A simplified transaction relevant to a specific address. */ +export interface Transaction { + /** Transaction ID (hex). */ + txid: string; + /** Net satoshi change for the address (positive = received, negative = sent). */ + amount: number; + /** Whether this is a receive or send relative to the address. */ + type: 'receive' | 'send'; + /** Whether the transaction is confirmed. */ + confirmed: boolean; + /** Unix timestamp of the block (undefined if unconfirmed). */ + timestamp?: number; +} + +/** + * Fetch transactions for a Bitcoin address from the Blockstream 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`); + + if (!response.ok) { + throw new Error('Failed to fetch transactions'); + } + + const txs = await response.json(); + + return txs.map((tx: Record) => { + const vin = tx.vin as Array<{ prevout: { scriptpubkey_address?: string; value: number } | null }>; + const vout = tx.vout as Array<{ scriptpubkey_address?: string; value: number }>; + const status = tx.status as { confirmed: boolean; block_time?: number }; + + // Sum sats flowing out of this address (inputs we owned) + const totalIn = vin.reduce((sum, input) => { + if (input.prevout?.scriptpubkey_address === address) { + return sum + input.prevout.value; + } + return sum; + }, 0); + + // Sum sats flowing into this address (outputs we own) + const totalOut = vout.reduce((sum, output) => { + if (output.scriptpubkey_address === address) { + return sum + output.value; + } + return sum; + }, 0); + + const net = totalOut - totalIn; + + return { + txid: tx.txid as string, + amount: Math.abs(net), + type: net >= 0 ? 'receive' : 'send', + confirmed: status.confirmed, + timestamp: status.block_time, + } satisfies Transaction; + }); +} diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index ea2deb41..c1cbbcf6 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Bitcoin, Copy, Check, RefreshCw, Wallet, ExternalLink } from 'lucide-react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ExternalLink, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { PageHeader } from '@/components/PageHeader'; import { LoginArea } from '@/components/auth/LoginArea'; @@ -11,11 +12,12 @@ import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; import { satsToBTC, satsToUSD } from '@/lib/bitcoin'; +import type { Transaction } from '@/lib/bitcoin'; export function WalletPage() { const { config } = useAppContext(); const { user } = useCurrentUser(); - const { bitcoinAddress, addressData, btcPrice, isLoading, error, refetch } = useBitcoinWallet(); + const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, isLoadingTxs, error, refetch } = useBitcoinWallet(); const [copiedAddress, setCopiedAddress] = useState(false); @@ -122,8 +124,102 @@ export function WalletPage() { View on explorer + + {/* Transactions */} + {isLoadingTxs ? ( +
+ + {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + +
+
+ +
+ ))} +
+ ) : transactions && transactions.length > 0 ? ( +
+ +
+ {transactions.map((tx) => ( + + ))} +
+
+ ) : transactions && transactions.length === 0 ? ( +
+ +

+ No transactions yet +

+
+ ) : null} )} ); } + +/** Format a unix timestamp as a relative or absolute date. */ +function formatTxDate(timestamp?: number): string { + if (!timestamp) return 'Pending'; + + const date = new Date(timestamp * 1000); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +/** Single transaction row. */ +function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) { + const isReceive = tx.type === 'receive'; + + return ( + +
+
+ {isReceive + ? + : } +
+
+

{isReceive ? 'Received' : 'Sent'}

+

{formatTxDate(tx.timestamp)}

+
+
+
+

+ {isReceive ? '+' : '-'} + {btcPrice + ? satsToUSD(tx.amount, btcPrice) + : `${satsToBTC(tx.amount).replace(/\.?0+$/, '')} BTC`} +

+

+ {satsToBTC(tx.amount).replace(/\.?0+$/, '')} BTC +

+
+
+ ); +} From 4abc45a8491123f15ad014f97fe20031636bfa7c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:43:56 -0500 Subject: [PATCH 008/330] Replace explorer link with collapsible Transactions toggle Transactions button with chevron replaces the 'View on explorer' link. Clicking toggles the tx list open/closed with a smooth accordion slide using CSS grid-template-rows animation. Chevron rotates on open. --- src/pages/WalletPage.tsx | 83 ++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index c1cbbcf6..65199b6e 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,9 +1,8 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Bitcoin, Copy, Check, RefreshCw, Wallet, ExternalLink, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { PageHeader } from '@/components/PageHeader'; import { LoginArea } from '@/components/auth/LoginArea'; @@ -20,6 +19,7 @@ export function WalletPage() { const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, isLoadingTxs, error, refetch } = useBitcoinWallet(); const [copiedAddress, setCopiedAddress] = useState(false); + const [txOpen, setTxOpen] = useState(false); useSeoMeta({ title: `Wallet | ${config.appName}`, @@ -114,57 +114,66 @@ export function WalletPage() { )} - {/* Explorer link */} - setTxOpen((o) => !o)} + className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer" > - - View on explorer - + Transactions + + - {/* Transactions */} - {isLoadingTxs ? ( -
- - {Array.from({ length: 3 }).map((_, i) => ( -
-
- -
- - + {/* Transactions accordion */} + + {isLoadingTxs ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + +
+
- -
- ))} -
- ) : transactions && transactions.length > 0 ? ( -
- -
+ ))} +
+ ) : transactions && transactions.length > 0 ? ( +
{transactions.map((tx) => ( ))}
-
- ) : transactions && transactions.length === 0 ? ( -
- + ) : (

No transactions yet

-
- ) : null} + )} +
)} ); } +/** Accordion wrapper using grid-template-rows for smooth height animation. */ +function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) { + const contentRef = useRef(null); + + return ( +
+
+ {children} +
+
+ ); +} + /** Format a unix timestamp as a relative or absolute date. */ function formatTxDate(timestamp?: number): string { if (!timestamp) return 'Pending'; From 995088842af3d30a8d18ab7eb96f0e3428913651 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:49:52 -0500 Subject: [PATCH 009/330] Hide transactions button and list when there are no transactions --- src/pages/WalletPage.tsx | 58 ++++++++++++++-------------------------- 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index 65199b6e..bd5e89a6 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -16,7 +16,7 @@ import type { Transaction } from '@/lib/bitcoin'; export function WalletPage() { const { config } = useAppContext(); const { user } = useCurrentUser(); - const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, isLoadingTxs, error, refetch } = useBitcoinWallet(); + const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, error, refetch } = useBitcoinWallet(); const [copiedAddress, setCopiedAddress] = useState(false); const [txOpen, setTxOpen] = useState(false); @@ -114,44 +114,26 @@ export function WalletPage() { )} - {/* Transactions toggle */} - + {/* Transactions */} + {transactions && transactions.length > 0 && ( + <> + - {/* Transactions accordion */} - - {isLoadingTxs ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
-
- -
- - -
-
- -
- ))} -
- ) : transactions && transactions.length > 0 ? ( -
- {transactions.map((tx) => ( - - ))} -
- ) : ( -

- No transactions yet -

- )} -
+ +
+ {transactions.map((tx) => ( + + ))} +
+
+ + )}
)} From 773592f9ddb179fc408644fea68bb5807f492cbb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:57:28 -0500 Subject: [PATCH 010/330] Make block explorer URLs configurable via AppConfig URI templates Add blockExplorerAddress and blockExplorerTx fields as RFC 6570 URI templates with {address} and {txid} variables respectively. Default to mempool.space instead of blockstream.info. Wallet page uses UriTemplate to fill the configured templates. --- src/App.tsx | 2 ++ src/contexts/AppContext.ts | 4 ++++ src/lib/schemas.ts | 2 ++ src/pages/WalletPage.tsx | 8 +++++--- src/test/TestApp.tsx | 2 ++ 5 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1ef943aa..0299ec05 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -149,6 +149,8 @@ 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/contexts/AppContext.ts b/src/contexts/AppContext.ts index 492244ae..a7eca7dd 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -241,6 +241,10 @@ 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/lib/schemas.ts b/src/lib/schemas.ts index ee034e12..39a2ad5f 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -245,6 +245,8 @@ 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/WalletPage.tsx b/src/pages/WalletPage.tsx index bd5e89a6..a60d5930 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; 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'; @@ -128,7 +129,7 @@ export function WalletPage() {
{transactions.map((tx) => ( - + ))}
@@ -173,12 +174,13 @@ function formatTxDate(timestamp?: number): string { } /** Single transaction row. */ -function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) { +function TxRow({ tx, btcPrice, txUrlTemplate }: { tx: Transaction; btcPrice?: number; txUrlTemplate: string }) { const isReceive = tx.type === 'receive'; + const txUrl = UriTemplate(txUrlTemplate).fill({ txid: tx.txid }); return ( Date: Mon, 6 Apr 2026 20:58:17 -0500 Subject: [PATCH 011/330] 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 --- WALLET.md | 23 +- src/App.tsx | 2 - src/components/BitcoinContentHeader.tsx | 567 +++++++++++++++++++++++ src/components/ExternalContentHeader.tsx | 5 + src/contexts/AppContext.ts | 4 - src/hooks/useBitcoinAddress.ts | 25 + src/hooks/useBitcoinTx.ts | 25 + src/lib/bitcoin.ts | 159 ++++++- src/lib/externalContent.ts | 24 + src/lib/schemas.ts | 2 - src/pages/ExternalContentPage.tsx | 3 + src/pages/WalletPage.tsx | 15 +- src/test/TestApp.tsx | 2 - 13 files changed, 826 insertions(+), 30 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/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 */} +
+
+ ); +} + +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, 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 ( From 64bac107585e8ef7801bd116b023e7615f8f7f18 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 21:14:37 -0500 Subject: [PATCH 012/330] Fix baseline alignment of Bitcoin txid/address in comment context rows The link was using inline-flex items-center with child spans at different font sizes (text-sm 'transaction' + text-xs monospace hash). Flexbox center-aligns by box center, not text baseline, causing the smaller text to appear shifted up. Changed to plain inline text flow so the browser's natural baseline alignment handles mixed font sizes correctly. --- src/components/BitcoinContentHeader.tsx | 79 +++++++++++++++++++++++-- src/components/CommentContext.tsx | 79 ++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/src/components/BitcoinContentHeader.tsx b/src/components/BitcoinContentHeader.tsx index 790b54b5..bba2e54d 100644 --- a/src/components/BitcoinContentHeader.tsx +++ b/src/components/BitcoinContentHeader.tsx @@ -519,11 +519,30 @@ function AddressSkeleton() { } // --------------------------------------------------------------------------- -// Compact previews (used in NoteCard embeds, etc.) +// Compact previews (used in NoteCard embeds, hover cards, etc.) // --------------------------------------------------------------------------- -/** Compact preview for a Bitcoin transaction (used in ExternalContentPreview). */ +/** 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'} + + )}
-

{truncateMiddle(txid, 12, 8)}

+

+ {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 (used in ExternalContentPreview). */ +/** 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 -

{truncateMiddle(address, 12, 8)}

+

+ {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/components/CommentContext.tsx b/src/components/CommentContext.tsx index 1285f587..74be613e 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -3,13 +3,14 @@ import { type ReactNode, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { - Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film, + Award, BarChart3, Bitcoin, BookOpen, Camera, Clapperboard, Egg, FileText, Film, GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music, Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles, Users, Vote, Zap, } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; +import { BitcoinTxPreview, BitcoinAddressPreview } from '@/components/BitcoinContentHeader'; import { CardsIcon } from '@/components/icons/CardsIcon'; import { ChestIcon } from '@/components/icons/ChestIcon'; import { RepostIcon } from '@/components/icons/RepostIcon'; @@ -672,6 +673,16 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN return ; } + // Bitcoin transaction identifiers — show icon + truncated txid with hover preview + if (identifier.startsWith('bitcoin:tx:')) { + return ; + } + + // Bitcoin address identifiers — show icon + truncated address with hover preview + if (identifier.startsWith('bitcoin:address:')) { + return ; + } + // Generic fallback for other external identifiers const link = `/i/${encodeURIComponent(identifier)}`; @@ -847,3 +858,69 @@ function IsbnCommentContext({ identifier, className }: { identifier: string; cla ); } + +/** Comment context for Bitcoin transaction identifiers — shows icon, truncated txid, and hover preview. */ +function BitcoinTxCommentContext({ identifier, className }: { identifier: string; className?: string }) { + const txid = identifier.slice('bitcoin:tx:'.length); + const link = `/i/${encodeURIComponent(identifier)}`; + const truncated = txid.length > 19 ? `${txid.slice(0, 8)}…${txid.slice(-8)}` : txid; + + return ( + + + + + e.stopPropagation()} + > + transaction {truncated} + + + e.stopPropagation()} + > + + + + + ); +} + +/** Comment context for Bitcoin address identifiers — shows icon, truncated address, and hover preview. */ +function BitcoinAddressCommentContext({ identifier, className }: { identifier: string; className?: string }) { + const address = identifier.slice('bitcoin:address:'.length); + const link = `/i/${encodeURIComponent(identifier)}`; + const truncated = address.length > 19 ? `${address.slice(0, 8)}…${address.slice(-8)}` : address; + + return ( + + + + + e.stopPropagation()} + > + address {truncated} + + + e.stopPropagation()} + > + + + + + ); +} From c49afc7add6f98eeddc3d890d771e54894d5998d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 21:35:13 -0500 Subject: [PATCH 013/330] Add Bitcoin send functionality with 3-step confirmation flow Implement sending Bitcoin transactions from the wallet page. The send flow uses a 3-step dialog: form entry, confirmation review, and success result. Only available for nsec logins since extension/bunker signers don't expose the raw private key needed for Taproot signing. Fixes over the reference implementation: - Send Max correctly subtracts estimated fees - Address validation via bitcoinjs-lib (checksum + format) - Fee estimation accounts for actual output count (1 vs 2) - Confirmation step before broadcast (irreversible action) - All API calls use mempool.space (consistent with existing code) - Success links to in-app NIP-73 tx detail page New files: - src/hooks/useNsecAccess.ts: extract private key from nsec login - src/components/SendBitcoinDialog.tsx: 3-step send dialog New functions in src/lib/bitcoin.ts: - fetchUTXOs, getFeeRates, broadcastTransaction - validateBitcoinAddress, estimateFee, maxSendable - createBitcoinTransaction (PSBT construction + Taproot signing) - npubToBitcoinAddress, btcToSats --- src/components/SendBitcoinDialog.tsx | 612 +++++++++++++++++++++++++++ src/hooks/useNsecAccess.ts | 43 ++ src/lib/bitcoin.ts | 241 +++++++++++ src/pages/WalletPage.tsx | 23 +- 4 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 src/components/SendBitcoinDialog.tsx create mode 100644 src/hooks/useNsecAccess.ts diff --git a/src/components/SendBitcoinDialog.tsx b/src/components/SendBitcoinDialog.tsx new file mode 100644 index 00000000..59d7db7c --- /dev/null +++ b/src/components/SendBitcoinDialog.tsx @@ -0,0 +1,612 @@ +import { useState, useCallback, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { + ArrowUpRight, + AlertTriangle, + Check, + ChevronLeft, + Loader2, + Send, +} from 'lucide-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Skeleton } from '@/components/ui/skeleton'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNsecAccess } from '@/hooks/useNsecAccess'; +import { useToast } from '@/hooks/useToast'; +import { + nostrPubkeyToBitcoinAddress, + npubToBitcoinAddress, + validateBitcoinAddress, + fetchUTXOs, + getFeeRates, + createBitcoinTransaction, + broadcastTransaction, + estimateFee, + maxSendable, + satsToBTC, + btcToSats, + satsToUSD, + formatSats, +} from '@/lib/bitcoin'; +import type { FeeRates, UTXO } from '@/lib/bitcoin'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy'; + +type Step = 'form' | 'confirm' | 'success'; + +interface SendBitcoinDialogProps { + isOpen: boolean; + onClose: () => void; + /** BTC/USD price — passed from the parent to avoid a duplicate fetch. */ + btcPrice?: number; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const FEE_SPEED_LABELS: Record = { + fastest: 'Fastest (~10 min)', + halfHour: 'Half hour', + hour: 'One hour', + economy: 'Economy (~1 day)', +}; + +function feeRateForSpeed(rates: FeeRates, speed: FeeSpeed): number { + const map: Record = { + fastest: rates.fastestFee, + halfHour: rates.halfHourFee, + hour: rates.hourFee, + economy: rates.economyFee, + }; + return map[speed]; +} + +/** Resolve a recipient string to a Bitcoin address, or throw. */ +function resolveRecipient(input: string): string { + const trimmed = input.trim(); + if (trimmed.startsWith('npub1')) { + return npubToBitcoinAddress(trimmed); + } + if (validateBitcoinAddress(trimmed)) { + return trimmed; + } + throw new Error('Invalid recipient. Enter an npub or a Bitcoin address (bc1...).'); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDialogProps) { + const { user } = useCurrentUser(); + const { hasNsecAccess, getPrivateKeyHex } = useNsecAccess(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + // Form state + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [feeSpeed, setFeeSpeed] = useState('halfHour'); + const [error, setError] = useState(''); + + // Multi-step state + const [step, setStep] = useState('form'); + const [txId, setTxId] = useState(''); + const [confirmedFee, setConfirmedFee] = useState(0); + + const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : ''; + + // ── Data fetching ────────────────────────────────────────────── + + const { data: utxos, isLoading: isLoadingUtxos } = useQuery({ + queryKey: ['bitcoin-utxos', senderAddress], + queryFn: () => fetchUTXOs(senderAddress), + enabled: !!senderAddress && isOpen, + staleTime: 30_000, + }); + + const { data: feeRates, isLoading: isLoadingFees } = useQuery({ + queryKey: ['bitcoin-fee-rates'], + queryFn: getFeeRates, + enabled: isOpen, + staleTime: 30_000, + }); + + const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]); + + const currentFeeRate = feeRates ? feeRateForSpeed(feeRates, feeSpeed) : 0; + + // ── Derived values for the confirm screen ────────────────────── + + const parsedAmountSats = useMemo(() => { + const n = parseFloat(amount); + return isNaN(n) || n <= 0 ? 0 : btcToSats(n); + }, [amount]); + + const resolvedRecipient = useMemo(() => { + try { return resolveRecipient(recipient); } catch { return ''; } + }, [recipient]); + + const previewFee = useMemo(() => { + if (!utxos?.length || !currentFeeRate || !parsedAmountSats) return 0; + // Estimate with 2 outputs first, then check if change would be below dust + const fee2 = estimateFee(utxos.length, 2, currentFeeRate); + const change = totalBalance - parsedAmountSats - fee2; + const numOutputs = change > 546 ? 2 : 1; + return estimateFee(utxos.length, numOutputs, currentFeeRate); + }, [utxos, currentFeeRate, parsedAmountSats, totalBalance]); + + // ── Send Max ─────────────────────────────────────────────────── + + const handleSendMax = useCallback(() => { + if (!utxos?.length || !currentFeeRate) return; + const max = maxSendable(totalBalance, utxos.length, currentFeeRate); + if (max <= 0) return; + setAmount(satsToBTC(max).replace(/\.?0+$/, '')); + setError(''); + }, [utxos, currentFeeRate, totalBalance]); + + // ── Send mutation ────────────────────────────────────────────── + + const sendMutation = useMutation({ + mutationFn: async () => { + if (!user || !hasNsecAccess) throw new Error('Nsec login required.'); + const privateKey = getPrivateKeyHex(); + if (!privateKey) throw new Error('Failed to access private key.'); + if (!utxos?.length) throw new Error('No UTXOs available.'); + if (!feeRates) throw new Error('Fee rates not loaded.'); + + const recipientAddress = resolveRecipient(recipient); + const amountSats = btcToSats(parseFloat(amount)); + if (isNaN(amountSats) || amountSats <= 0) throw new Error('Invalid amount.'); + + const feeRate = feeRateForSpeed(feeRates, feeSpeed); + + const { txHex, fee } = createBitcoinTransaction( + privateKey, + recipientAddress, + amountSats, + utxos, + feeRate, + ); + + const id = await broadcastTransaction(txHex); + return { txId: id, fee }; + }, + onSuccess: ({ txId: id, fee }) => { + setTxId(id); + setConfirmedFee(fee); + setStep('success'); + toast({ title: 'Transaction sent', description: `Fee: ${formatSats(fee)} sats` }); + + // Invalidate wallet data so balance updates + queryClient.invalidateQueries({ queryKey: ['bitcoin-wallet'] }); + queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] }); + }, + onError: (err: Error) => { + setError(err.message); + setStep('form'); + toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' }); + }, + }); + + // ── Navigation ───────────────────────────────────────────────── + + const goToConfirm = () => { + setError(''); + try { + resolveRecipient(recipient); + } catch (err) { + setError(err instanceof Error ? err.message : 'Invalid recipient'); + return; + } + const sats = btcToSats(parseFloat(amount)); + if (isNaN(sats) || sats <= 0) { setError('Enter a valid amount.'); return; } + if (sats + previewFee > totalBalance) { setError('Insufficient funds.'); return; } + setStep('confirm'); + }; + + const handleClose = () => { + setRecipient(''); + setAmount(''); + setError(''); + setTxId(''); + setConfirmedFee(0); + setStep('form'); + setFeeSpeed('halfHour'); + onClose(); + }; + + // ── Render ───────────────────────────────────────────────────── + + // Not logged in with nsec + if (isOpen && !hasNsecAccess) { + return ( + + + + + + Nsec Required + + + Sending Bitcoin requires access to your private key. + + + + + + This feature is only available when logged in with your nsec (secret key). + Browser extensions and remote signers don't expose the raw private key + needed to sign Bitcoin transactions. + + + + + + ); + } + + return ( + + + {step === 'success' ? ( + + ) : step === 'confirm' ? ( + setStep('form')} + onConfirm={() => sendMutation.mutate()} + /> + ) : ( + { setRecipient(v); setError(''); }} + onAmountChange={(v) => { setAmount(v); setError(''); }} + onFeeSpeedChange={setFeeSpeed} + onSendMax={handleSendMax} + onNext={goToConfirm} + onCancel={handleClose} + /> + )} + + + ); +} + +// ═══════════════════════════════════════════════════════════════════ +// Sub-views +// ═══════════════════════════════════════════════════════════════════ + +// ── Form ───────────────────────────────────────────────────────── + +interface FormViewProps { + recipient: string; + amount: string; + feeSpeed: FeeSpeed; + error: string; + totalBalance: number; + btcPrice?: number; + utxos?: UTXO[]; + feeRates?: FeeRates; + isLoadingUtxos: boolean; + isLoadingFees: boolean; + currentFeeRate: number; + onRecipientChange: (v: string) => void; + onAmountChange: (v: string) => void; + onFeeSpeedChange: (v: FeeSpeed) => void; + onSendMax: () => void; + onNext: () => void; + onCancel: () => void; +} + +function FormView({ + recipient, amount, feeSpeed, error, totalBalance, btcPrice, + feeRates, isLoadingUtxos, isLoadingFees, currentFeeRate, + onRecipientChange, onAmountChange, onFeeSpeedChange, onSendMax, onNext, onCancel, +}: FormViewProps) { + const parsedBtc = parseFloat(amount) || 0; + + return ( + <> + + + + Send Bitcoin + + + Send Bitcoin to a Nostr user or Bitcoin address + + + +
+ {/* Balance */} +
+ + {isLoadingUtxos ? ( + + ) : ( +

+ {btcPrice + ? satsToUSD(totalBalance, btcPrice) + : `${satsToBTC(totalBalance).replace(/\.?0+$/, '')} BTC`} +

+ )} +
+ + {/* Recipient */} +
+ + onRecipientChange(e.target.value)} + /> +

Nostr npub or Bitcoin address

+
+ + {/* Amount */} +
+ + onAmountChange(e.target.value)} + /> +
+ + {parsedBtc > 0 + ? btcPrice + ? satsToUSD(btcToSats(parsedBtc), btcPrice) + : `${formatSats(btcToSats(parsedBtc))} sats` + : ''} + + +
+
+ + {/* Fee speed */} +
+ + + {currentFeeRate > 0 && parsedBtc > 0 && ( +

+ Estimated fee: ~{formatSats(estimateFee(1, 2, currentFeeRate))} sats +

+ )} +
+ + {/* Error */} + {error && ( + + + {error} + + )} + + {/* Warning */} + + + + Warning: This is an experimental feature. Test with small amounts first. + Transactions cannot be reversed. + + + + {/* Actions */} +
+ + +
+
+ + ); +} + +// ── Confirm ────────────────────────────────────────────────────── + +interface ConfirmViewProps { + recipient: string; + amountSats: number; + fee: number; + feeSpeed: FeeSpeed; + btcPrice?: number; + isPending: boolean; + onBack: () => void; + onConfirm: () => void; +} + +function ConfirmView({ recipient, amountSats, fee, feeSpeed, btcPrice, isPending, onBack, onConfirm }: ConfirmViewProps) { + const totalSats = amountSats + fee; + + const row = (label: string, sats: number) => ( +
+ {label} +
+ + {satsToBTC(sats).replace(/\.?0+$/, '')} BTC + + {btcPrice && ( + + ({satsToUSD(sats, btcPrice)}) + + )} +
+
+ ); + + const truncatedRecipient = recipient.length > 24 + ? `${recipient.slice(0, 12)}...${recipient.slice(-8)}` + : recipient; + + return ( + <> + + + + Confirm Transaction + + + Review the details before sending + + + +
+ {/* Recipient */} +
+ +

{truncatedRecipient}

+
+ + {/* Breakdown */} +
+ {row('Amount', amountSats)} + {row(`Fee (${FEE_SPEED_LABELS[feeSpeed].toLowerCase()})`, fee)} +
+ {row('Total', totalSats)} +
+
+ + {/* Actions */} +
+ + +
+
+ + ); +} + +// ── Success ────────────────────────────────────────────────────── + +interface SuccessViewProps { + txId: string; + fee: number; + btcPrice?: number; + onClose: () => void; +} + +function SuccessView({ txId, fee, btcPrice, onClose }: SuccessViewProps) { + return ( + <> + + + + Transaction Sent + + + Your transaction has been broadcast to the Bitcoin network. + + + +
+
+ +

{txId}

+
+ +

+ Fee: {formatSats(fee)} sats + {btcPrice ? ` (${satsToUSD(fee, btcPrice)})` : ''} +

+ +
+ + +
+
+ + ); +} diff --git a/src/hooks/useNsecAccess.ts b/src/hooks/useNsecAccess.ts new file mode 100644 index 00000000..88fa65bd --- /dev/null +++ b/src/hooks/useNsecAccess.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; +import { useNostrLogin } from '@nostrify/react/login'; +import { nip19 } from 'nostr-tools'; + +/** + * Hook that checks whether the current login is an nsec and, if so, provides + * a function to retrieve the raw 32-byte private key as a hex string. + * + * Only nsec logins expose the raw secret key — extension and bunker logins + * do not, so Bitcoin transaction signing is only possible with nsec. + */ +export function useNsecAccess() { + const { logins } = useNostrLogin(); + const currentLogin = logins[0]; + + const hasNsecAccess = currentLogin?.type === 'nsec'; + + const getPrivateKeyHex = useMemo(() => { + return (): string | null => { + if (currentLogin?.type !== 'nsec') return null; + + try { + const decoded = nip19.decode(currentLogin.data.nsec); + if (decoded.type !== 'nsec') return null; + + // decoded.data is a Uint8Array for nsec + return Buffer.from(decoded.data).toString('hex'); + } catch (error) { + console.error('Failed to decode nsec:', error); + return null; + } + }; + }, [currentLogin]); + + return { + /** Whether the current login exposes the raw private key. */ + hasNsecAccess, + /** Retrieve the 32-byte private key as hex. Returns null if not an nsec login. */ + getPrivateKeyHex, + /** The login type of the current user (nsec, bunker, extension, etc.). */ + loginType: currentLogin?.type, + }; +} diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index c24aa2a6..323289e6 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -1,4 +1,8 @@ import * as bitcoin from 'bitcoinjs-lib'; +import { toXOnly } from 'bitcoinjs-lib'; +import { nip19 } from 'nostr-tools'; +import * as ecc from '@bitcoinerlab/secp256k1'; +import { ECPairFactory, type ECPairAPI } from 'ecpair'; // --------------------------------------------------------------------------- // Constants @@ -7,6 +11,32 @@ import * as bitcoin from 'bitcoinjs-lib'; /** Base URL for the mempool.space Esplora-compatible REST API. */ const MEMPOOL_API = 'https://mempool.space/api'; +/** Standard Bitcoin dust limit in satoshis. */ +const DUST_LIMIT = 546; + +/** Estimated vBytes per P2TR input. */ +const VBYTES_PER_INPUT = 57.5; + +/** Estimated vBytes per P2TR output. */ +const VBYTES_PER_OUTPUT = 43; + +/** Estimated vBytes for transaction overhead (version, locktime, etc.). */ +const VBYTES_OVERHEAD = 10.5; + +// --------------------------------------------------------------------------- +// ECC initialisation (lazy) +// --------------------------------------------------------------------------- + +let _ECPair: ECPairAPI | null = null; + +function getECPair(): ECPairAPI { + if (!_ECPair) { + bitcoin.initEccLib(ecc); + _ECPair = ECPairFactory(ecc); + } + return _ECPair; +} + /** * Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address. * @@ -30,6 +60,18 @@ export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string { } } +/** + * Convert a bech32 `npub1...` identifier to a Bitcoin Taproot (P2TR) address. + * Decodes the npub to a hex pubkey, then delegates to {@link nostrPubkeyToBitcoinAddress}. + */ +export function npubToBitcoinAddress(npub: string): string { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + throw new Error('Invalid npub format'); + } + return nostrPubkeyToBitcoinAddress(decoded.data); +} + // --------------------------------------------------------------------------- // Balance / Address data (wallet page) // --------------------------------------------------------------------------- @@ -107,6 +149,11 @@ export async function fetchBtcPrice(): Promise { return data.bitcoin.usd; } +/** Convert a BTC amount to satoshis (rounded to nearest integer). */ +export function btcToSats(btc: number): number { + return Math.round(btc * 100_000_000); +} + /** Convert satoshis to USD given a BTC price. */ export function satsToUSD(sats: number, btcPrice: number): string { const btc = sats / 100_000_000; @@ -311,3 +358,197 @@ export async function fetchAddressDetail(address: string): Promise { + const response = await fetch(`${MEMPOOL_API}/address/${address}/utxo`); + if (!response.ok) throw new Error('Failed to fetch UTXOs'); + return response.json(); +} + +/** Fee rate estimates keyed by confirmation speed. */ +export interface FeeRates { + /** ~10 min / next block (target 1). */ + fastestFee: number; + /** ~30 min (target 3). */ + halfHourFee: number; + /** ~1 hour (target 6). */ + hourFee: number; + /** ~1 day (target 144). */ + economyFee: number; + /** Minimum relay fee (target 504). */ + minimumFee: number; +} + +/** Fetch recommended fee rates (sat/vB) from mempool.space. */ +export async function getFeeRates(): Promise { + const response = await fetch(`${MEMPOOL_API}/fee-estimates`); + if (!response.ok) throw new Error('Failed to fetch fee estimates'); + + const data = await response.json(); + + return { + fastestFee: Math.ceil(data['1'] || 1), + halfHourFee: Math.ceil(data['3'] || 1), + hourFee: Math.ceil(data['6'] || 1), + economyFee: Math.ceil(data['144'] || 1), + minimumFee: Math.ceil(data['504'] || 1), + }; +} + +/** + * Estimate the fee for a P2TR transaction in satoshis. + * + * @param numInputs Number of Taproot inputs. + * @param numOutputs Number of outputs (recipient + optional change). + * @param feeRate Fee rate in sat/vB. + */ +export function estimateFee(numInputs: number, numOutputs: number, feeRate: number): number { + const vBytes = numInputs * VBYTES_PER_INPUT + numOutputs * VBYTES_PER_OUTPUT + VBYTES_OVERHEAD; + return Math.ceil(vBytes * feeRate); +} + +/** + * Validate a Bitcoin address (mainnet). Returns `true` if the address has a + * valid format and checksum, `false` otherwise. + */ +export function validateBitcoinAddress(address: string): boolean { + try { + bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin); + return true; + } catch { + return false; + } +} + +/** Broadcast a signed transaction hex to the Bitcoin network via mempool.space. Returns the txid. */ +export async function broadcastTransaction(txHex: string): Promise { + const response = await fetch(`${MEMPOOL_API}/tx`, { + method: 'POST', + body: txHex, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Broadcast failed: ${body}`); + } + + return response.text(); +} + +/** + * Compute the maximum sendable amount (in sats) after fees. + * + * @param totalBalance Total spendable sats across all UTXOs. + * @param numInputs Number of UTXOs that will be consumed. + * @param feeRate Fee rate in sat/vB. + * @returns The max amount in sats, or 0 if the balance cannot cover fees. + */ +export function maxSendable(totalBalance: number, numInputs: number, feeRate: number): number { + // When sending max there is no change output, so only 1 output. + const fee = estimateFee(numInputs, 1, feeRate); + return Math.max(0, totalBalance - fee); +} + +/** + * Create, sign, and return a raw Bitcoin Taproot transaction. + * + * @param privateKeyHex 32-byte hex private key (from Nostr nsec). + * @param toAddress Recipient Bitcoin address. + * @param amountSats Amount to send in satoshis. + * @param utxos Available UTXOs (all will be consumed). + * @param feeRate Fee rate in sat/vB. + * @returns The signed transaction hex and the fee paid. + */ +export function createBitcoinTransaction( + privateKeyHex: string, + toAddress: string, + amountSats: number, + utxos: UTXO[], + feeRate: number, +): { txHex: string; fee: number } { + // 1. Key pair from raw private key + const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex')); + const internalPubkey = toXOnly(keyPair.publicKey); + + // 2. Derive change address (same Taproot address as sender) + const { address: changeAddress } = bitcoin.payments.p2tr({ + internalPubkey, + network: bitcoin.networks.bitcoin, + }); + if (!changeAddress) throw new Error('Failed to derive change address'); + + // 3. Build PSBT, add all UTXOs as inputs + const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin }); + let totalInput = 0; + + for (const utxo of utxos) { + psbt.addInput({ + hash: utxo.txid, + index: utxo.vout, + witnessUtxo: { + script: bitcoin.payments.p2tr({ + internalPubkey, + network: bitcoin.networks.bitcoin, + }).output!, + value: BigInt(utxo.value), + }, + tapInternalKey: internalPubkey, + }); + totalInput += utxo.value; + } + + // 4. Estimate fee — first assume 2 outputs (recipient + change) + const change2Out = totalInput - amountSats - estimateFee(utxos.length, 2, feeRate); + const hasChange = change2Out > DUST_LIMIT; + const numOutputs = hasChange ? 2 : 1; + const fee = estimateFee(utxos.length, numOutputs, feeRate); + const change = totalInput - amountSats - fee; + + if (change < 0) { + throw new Error( + `Insufficient funds. Need ${(amountSats + fee).toLocaleString()} sats, have ${totalInput.toLocaleString()} sats.`, + ); + } + + // 5. Add outputs + psbt.addOutput({ address: toAddress, value: BigInt(amountSats) }); + + if (hasChange) { + psbt.addOutput({ address: changeAddress, value: BigInt(change) }); + } + + // 6. Tweak private key for Taproot key-path spending (BIP-341) + const tweakedSigner = keyPair.tweak( + bitcoin.crypto.taggedHash('TapTweak', internalPubkey), + ); + + // 7. Sign all inputs + for (let i = 0; i < utxos.length; i++) { + psbt.signInput(i, tweakedSigner); + } + + // 8. Finalize and extract + psbt.finalizeAllInputs(); + const tx = psbt.extractTransaction(); + + return { txHex: tx.toHex(), fee }; +} diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index c880f93d..212cb571 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,13 +1,14 @@ import { useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; -import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight, Send } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { PageHeader } from '@/components/PageHeader'; import { LoginArea } from '@/components/auth/LoginArea'; import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { SendBitcoinDialog } from '@/components/SendBitcoinDialog'; import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; @@ -21,6 +22,7 @@ export function WalletPage() { const [copiedAddress, setCopiedAddress] = useState(false); const [txOpen, setTxOpen] = useState(false); + const [sendOpen, setSendOpen] = useState(false); useSeoMeta({ title: `Wallet | ${config.appName}`, @@ -97,6 +99,25 @@ export function WalletPage() { ) : null} + {/* Send button */} + {addressData && ( + + )} + + setSendOpen(false)} + btcPrice={btcPrice} + /> + {/* QR Code */}
From aa618edc43a5b45bae2c0929feb756b31cf7d709 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 23:06:34 -0500 Subject: [PATCH 014/330] Document Bitcoin sending flow in WALLET.md --- WALLET.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/WALLET.md b/WALLET.md index 0113ee7b..f1f4ea49 100644 --- a/WALLET.md +++ b/WALLET.md @@ -110,6 +110,75 @@ Transaction and address detail pages use [NIP-73](https://github.com/nostr-proto These pages are part of the existing `/i/*` external content system, which also supports URLs, ISBNs, country codes, and other NIP-73 identifier types. +## Sending Bitcoin + +The wallet supports sending Bitcoin transactions directly from the app. Because Nostr and Bitcoin Taproot share the same private key, the Nostr nsec can sign Bitcoin transactions without any key conversion. + +### Requirements + +Sending is only available when logged in with an **nsec**. Extension (NIP-07) and bunker (NIP-46) logins do not expose the raw private key, which is required for Taproot key-path signing. Users on those login types see an explanation in the send dialog. + +### Transaction Construction + +The send flow constructs a standard Taproot (P2TR) key-path spend: + +1. **Fetch UTXOs** -- All unspent outputs for the sender's address are retrieved from `mempool.space/api/address/{address}/utxo`. + +2. **Fetch fee rates** -- Recommended fee rates (sat/vB) for four confirmation targets are retrieved from `mempool.space/api/fee-estimates`: + + | Speed | Block target | Typical wait | + |---|---|---| + | Fastest | 1 block | ~10 minutes | + | Half hour | 3 blocks | ~30 minutes | + | One hour | 6 blocks | ~1 hour | + | Economy | 144 blocks | ~1 day | + +3. **Resolve recipient** -- The recipient can be an `npub1...` (converted to its Taproot address via `npubToBitcoinAddress`) or a raw Bitcoin address (validated via `bitcoin.address.toOutputScript`). + +4. **Build PSBT** -- All UTXOs are consumed as inputs (no coin selection). Each input includes: + - `witnessUtxo`: the P2TR output script and value + - `tapInternalKey`: the 32-byte x-only public key + +5. **Estimate fee** -- The formula is `ceil((numInputs * 57.5 + numOutputs * 43 + 10.5) * feeRate)`. The output count is determined dynamically: if the change would be below the 546-sat dust limit, it is dropped (donated as extra miner fee) and the estimate uses 1 output instead of 2. + +6. **Add outputs** -- The recipient output is always added. A change output (back to the sender's own Taproot address) is added only if the change exceeds the dust limit. + +7. **Tweak and sign** -- For Taproot key-path spending (BIP-341), the private key is tweaked: + + ``` + tweak = taggedHash("TapTweak", internalPubkey) + tweakedKey = privateKey + tweak (mod n, with y-parity correction) + ``` + + The `ecpair` library's `.tweak()` method handles parity normalization automatically (negating the private key if the public key has an odd y-coordinate). Each input is signed with the tweaked signer using Schnorr signatures. + +8. **Broadcast** -- The finalized transaction hex is POSTed to `mempool.space/api/tx`, which returns the txid on success. + +### User Flow + +The send dialog has three steps: + +1. **Form** -- Enter recipient, amount, and fee speed. Shows available balance, USD conversion, and a "Send Max" button that correctly subtracts the estimated fee. +2. **Confirm** -- Review recipient address, amount (BTC + USD), fee breakdown, and total debit before committing. +3. **Success** -- Shows the transaction ID with a link to the in-app NIP-73 detail page (`/i/bitcoin:tx:{txid}`). + +### Additional API Endpoints + +| Endpoint | Purpose | +|---|---| +| `GET https://mempool.space/api/address/{address}/utxo` | Unspent transaction outputs | +| `GET https://mempool.space/api/fee-estimates` | Recommended fee rates by block target | +| `POST https://mempool.space/api/tx` | Broadcast signed transaction (raw hex body) | + +### Dependencies (sending-specific) + +| Package | Role | +|---|---| +| `ecpair` | Key pair creation and Taproot key tweaking | +| `tiny-secp256k1` | Low-level ECC operations (peer dep of ecpair) | + +These are in addition to the base dependencies listed above. + ## Security Considerations - The same private key (nsec in Nostr) controls both the Nostr identity and the Bitcoin funds at the derived address. From 5660a1cb1becf8b004d31f95d7a3e2547adc45ee Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 7 Apr 2026 00:16:36 -0500 Subject: [PATCH 015/330] Add multi-signer PSBT signing for Bitcoin transactions Split createBitcoinTransaction into buildUnsignedPsbt, signPsbtLocal, and finalizePsbt so the signing step can be delegated to any signer. Introduce BtcSigner interface and three extended signer classes: - NSecSignerBtc: local Taproot signing with the raw private key - NBrowserSignerBtc: delegates to window.nostr.signPsbt() (NIP-07) - NConnectSignerBtc: sends sign_psbt RPC over NIP-46 relay channel useCurrentUser now constructs Btc-extended signers instead of base ones. signerWithNudge forwards signPsbt when present on the wrapped signer. SendBitcoinDialog uses the new useBitcoinSigner hook instead of useNsecAccess, enabling sending from all login types. --- BITCOIN-SIGNING.md | 85 +++++++++++++++++++ WALLET.md | 35 ++++++-- src/components/SendBitcoinDialog.tsx | 36 ++++---- src/hooks/useBitcoinSigner.ts | 38 +++++++++ src/hooks/useCurrentUser.ts | 33 ++++++-- src/lib/bitcoin-signers.ts | 113 +++++++++++++++++++++++++ src/lib/bitcoin.ts | 120 +++++++++++++++++++++------ src/lib/signerWithNudge.ts | 9 ++ 8 files changed, 413 insertions(+), 56 deletions(-) create mode 100644 BITCOIN-SIGNING.md create mode 100644 src/hooks/useBitcoinSigner.ts create mode 100644 src/lib/bitcoin-signers.ts diff --git a/BITCOIN-SIGNING.md b/BITCOIN-SIGNING.md new file mode 100644 index 00000000..c1eba4d5 --- /dev/null +++ b/BITCOIN-SIGNING.md @@ -0,0 +1,85 @@ +# Bitcoin PSBT Signing for Nostr Signers + +This document specifies how Nostr signers (NIP-07 browser extensions and NIP-46 remote signers) can support signing Bitcoin Partially Signed Bitcoin Transactions (PSBTs). + +## Motivation + +Nostr and Bitcoin Taproot (BIP-341) share identical cryptographic primitives: secp256k1 with 32-byte x-only public keys and BIP-340 Schnorr signatures. This means a Nostr private key can directly sign Bitcoin Taproot transactions without any key conversion. To enable this, Nostr signers need a method to sign PSBTs. + +## `signPsbt` Method + +### NIP-07 (Browser Extensions) + +Extensions that support Bitcoin signing MUST expose a `signPsbt` method on the `window.nostr` object: + +```typescript +window.nostr.signPsbt(psbtHex: string): Promise +``` + +**Parameters:** + +- `psbtHex` — Hex-encoded PSBT (BIP-174/BIP-370). + +**Returns:** + +- A hex-encoded PSBT with Taproot key-path signatures (`tapKeySig`) added to matching inputs. + +### NIP-46 (Remote Signers) + +Remote signers that support Bitcoin signing MUST handle the `sign_psbt` RPC method: + +``` +method: "sign_psbt" +params: [""] +result: "" +``` + +The method follows the same NIP-46 request/response pattern as `sign_event`. If the signer does not support this method, it MUST return an error. + +## Signer Behavior + +When a signer receives a PSBT to sign, it MUST: + +1. Decode the PSBT from hex. +2. For each input, check if `tapInternalKey` is present. +3. Compare the input's `tapInternalKey` against the signer's own 32-byte x-only public key. +4. For each matching input: + a. Compute the BIP-341 tweak: `t = taggedHash("TapTweak", tapInternalKey)`. + b. Tweak the private key: apply `t` to the secret key with y-parity correction (negate the key if the corresponding public key has an odd y-coordinate, then add the tweak scalar modulo the curve order). + c. Compute the BIP-341 sighash for the input. + d. Produce a BIP-340 Schnorr signature over the sighash using the tweaked key. + e. Set `tapKeySig` on the input. +5. Return the PSBT with signatures added. The signer MUST NOT finalize or extract the transaction. + +Inputs whose `tapInternalKey` does not match the signer's key MUST be left unchanged. + +## Security Considerations + +- Signers SHOULD display a confirmation dialog showing the transaction outputs, amounts, and fees before signing. +- Signers SHOULD reject PSBTs that do not contain any inputs matching the signer's key. +- The PSBT format (BIP-174) carries all information needed for the signer to verify what is being signed, including input amounts (`witnessUtxo`) and output destinations. + +## Capability Detection + +### NIP-07 + +Clients SHOULD check for the presence of `signPsbt` before calling it: + +```typescript +if (typeof window.nostr?.signPsbt === 'function') { + const signedHex = await window.nostr.signPsbt(unsignedPsbtHex); +} +``` + +### NIP-46 + +Clients SHOULD handle errors gracefully when the remote signer does not support `sign_psbt`. If the signer returns an error for an unknown method, the client should inform the user that their signer does not support Bitcoin signing. + +## References + +- [BIP-174: Partially Signed Bitcoin Transaction Format](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) +- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) +- [BIP-341: Taproot (SegWit v1 Spending Rules)](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) +- [BIP-370: PSBT Version 2](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki) +- [NIP-07: `window.nostr` capability for web browsers](https://github.com/nostr-protocol/nips/blob/master/07.md) +- [NIP-46: Nostr Remote Signing](https://github.com/nostr-protocol/nips/blob/master/46.md) diff --git a/WALLET.md b/WALLET.md index f1f4ea49..8e1be2f3 100644 --- a/WALLET.md +++ b/WALLET.md @@ -112,11 +112,29 @@ These pages are part of the existing `/i/*` external content system, which also ## Sending Bitcoin -The wallet supports sending Bitcoin transactions directly from the app. Because Nostr and Bitcoin Taproot share the same private key, the Nostr nsec can sign Bitcoin transactions without any key conversion. +The wallet supports sending Bitcoin transactions directly from the app. Because Nostr and Bitcoin Taproot share the same private key, the Nostr key can sign Bitcoin transactions without any key conversion. -### Requirements +### Supported Signer Types -Sending is only available when logged in with an **nsec**. Extension (NIP-07) and bunker (NIP-46) logins do not expose the raw private key, which is required for Taproot key-path signing. Users on those login types see an explanation in the send dialog. +Sending works with all three login types: + +| Login type | Signing method | How it works | +|---|---|---| +| **nsec** (secret key) | Local signing | The app applies the BIP-341 TapTweak and signs the PSBT directly using the private key. | +| **NIP-07 extension** | `window.nostr.signPsbt(hex)` | The unsigned PSBT hex is passed to the extension, which handles tweaking and signing internally. | +| **NIP-46 bunker** | `sign_psbt` RPC | The unsigned PSBT hex is sent to the remote signer over the NIP-46 relay channel. | + +If the signer does not support PSBT signing (e.g. an extension without `signPsbt`), the send dialog displays an explanation and the user cannot proceed. + +### Architecture + +The send flow uses a three-step PSBT pipeline: + +1. **`buildUnsignedPsbt()`** -- Constructs the PSBT with all inputs and outputs but no signatures. Only needs the sender's public key. +2. **`signer.signPsbt()`** -- Signs the PSBT. Dispatched through the signer interface (local, extension, or bunker). +3. **`finalizePsbt()`** -- Finalizes all inputs and extracts the raw transaction hex for broadcast. + +The signer classes (`NSecSignerBtc`, `NBrowserSignerBtc`, `NConnectSignerBtc`) extend Nostrify's base signer classes with a `signPsbt` method. The `signerWithNudge` wrapper forwards `signPsbt` to the underlying signer, providing the same nudge toast UX for remote signers. ### Transaction Construction @@ -135,7 +153,7 @@ The send flow constructs a standard Taproot (P2TR) key-path spend: 3. **Resolve recipient** -- The recipient can be an `npub1...` (converted to its Taproot address via `npubToBitcoinAddress`) or a raw Bitcoin address (validated via `bitcoin.address.toOutputScript`). -4. **Build PSBT** -- All UTXOs are consumed as inputs (no coin selection). Each input includes: +4. **Build unsigned PSBT** -- All UTXOs are consumed as inputs (no coin selection). Each input includes: - `witnessUtxo`: the P2TR output script and value - `tapInternalKey`: the 32-byte x-only public key @@ -143,16 +161,16 @@ The send flow constructs a standard Taproot (P2TR) key-path spend: 6. **Add outputs** -- The recipient output is always added. A change output (back to the sender's own Taproot address) is added only if the change exceeds the dust limit. -7. **Tweak and sign** -- For Taproot key-path spending (BIP-341), the private key is tweaked: +7. **Sign** -- The unsigned PSBT is passed to the signer's `signPsbt` method. For local nsec signing, the private key is tweaked for Taproot key-path spending (BIP-341): ``` tweak = taggedHash("TapTweak", internalPubkey) tweakedKey = privateKey + tweak (mod n, with y-parity correction) ``` - The `ecpair` library's `.tweak()` method handles parity normalization automatically (negating the private key if the public key has an odd y-coordinate). Each input is signed with the tweaked signer using Schnorr signatures. + For extension and bunker signers, the tweaking is handled by the external signer. -8. **Broadcast** -- The finalized transaction hex is POSTed to `mempool.space/api/tx`, which returns the txid on success. +8. **Finalize and broadcast** -- The signed PSBT is finalized, the raw transaction hex is extracted, and POSTed to `mempool.space/api/tx`, which returns the txid on success. ### User Flow @@ -182,9 +200,10 @@ These are in addition to the base dependencies listed above. ## Security Considerations - The same private key (nsec in Nostr) controls both the Nostr identity and the Bitcoin funds at the derived address. -- Extension and bunker logins do not expose the raw private key, so spending Bitcoin from those login types requires exporting the key or using a compatible wallet application. +- Extension and bunker signers handle the private key internally -- the app never sees the raw key for those login types. Only the unsigned PSBT (which contains no secret material) is sent to the signer. - This is a **single-key** Taproot address with no HD derivation (no BIP-32/BIP-44 path). Every Nostr keypair maps to exactly one Bitcoin address. - Users should ensure they have secure backups of their Nostr private key before receiving Bitcoin at the derived address. +- Extensions and bunkers that support `signPsbt` / `sign_psbt` should display a confirmation dialog showing transaction outputs and amounts before signing. ## References diff --git a/src/components/SendBitcoinDialog.tsx b/src/components/SendBitcoinDialog.tsx index 59d7db7c..90d6855a 100644 --- a/src/components/SendBitcoinDialog.tsx +++ b/src/components/SendBitcoinDialog.tsx @@ -31,7 +31,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useNsecAccess } from '@/hooks/useNsecAccess'; +import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; import { useToast } from '@/hooks/useToast'; import { nostrPubkeyToBitcoinAddress, @@ -39,7 +39,8 @@ import { validateBitcoinAddress, fetchUTXOs, getFeeRates, - createBitcoinTransaction, + buildUnsignedPsbt, + finalizePsbt, broadcastTransaction, estimateFee, maxSendable, @@ -104,7 +105,7 @@ function resolveRecipient(input: string): string { export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDialogProps) { const { user } = useCurrentUser(); - const { hasNsecAccess, getPrivateKeyHex } = useNsecAccess(); + const { canSignPsbt, signPsbt } = useBitcoinSigner(); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -175,9 +176,7 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial const sendMutation = useMutation({ mutationFn: async () => { - if (!user || !hasNsecAccess) throw new Error('Nsec login required.'); - const privateKey = getPrivateKeyHex(); - if (!privateKey) throw new Error('Failed to access private key.'); + if (!user || !canSignPsbt || !signPsbt) throw new Error('Bitcoin signing not available.'); if (!utxos?.length) throw new Error('No UTXOs available.'); if (!feeRates) throw new Error('Fee rates not loaded.'); @@ -187,14 +186,21 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial const feeRate = feeRateForSpeed(feeRates, feeSpeed); - const { txHex, fee } = createBitcoinTransaction( - privateKey, + // 1. Build unsigned PSBT + const { psbtHex, fee } = buildUnsignedPsbt( + user.pubkey, recipientAddress, amountSats, utxos, feeRate, ); + // 2. Sign via the signer (local nsec, NIP-07 extension, or NIP-46 bunker) + const signedHex = await signPsbt(psbtHex); + + // 3. Finalize and extract raw tx + const txHex = finalizePsbt(signedHex); + const id = await broadcastTransaction(txHex); return { txId: id, fee }; }, @@ -244,26 +250,26 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial // ── Render ───────────────────────────────────────────────────── - // Not logged in with nsec - if (isOpen && !hasNsecAccess) { + // Signer doesn't support Bitcoin signing + if (isOpen && !canSignPsbt) { return ( - Nsec Required + Signing Not Available - Sending Bitcoin requires access to your private key. + Sending Bitcoin requires a signer that supports PSBT signing. - This feature is only available when logged in with your nsec (secret key). - Browser extensions and remote signers don't expose the raw private key - needed to sign Bitcoin transactions. + Your current signer does not support Bitcoin transaction signing. + Log in with your nsec, a NIP-07 extension that supports signPsbt, + or a NIP-46 remote signer that supports the sign_psbt method. diff --git a/src/hooks/useBitcoinSigner.ts b/src/hooks/useBitcoinSigner.ts new file mode 100644 index 00000000..a89d9188 --- /dev/null +++ b/src/hooks/useBitcoinSigner.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers'; + +/** + * Hook that exposes Bitcoin PSBT signing capability from the current user's signer. + * + * Works with all login types: + * - **nsec**: Signs locally using the Taproot-tweaked private key. + * - **extension (NIP-07)**: Delegates to `window.nostr.signPsbt()`. + * - **bunker (NIP-46)**: Sends `sign_psbt` RPC to the remote signer. + * + * Returns `canSignPsbt: false` if the user is not logged in or their signer + * doesn't support `signPsbt` (shouldn't happen with the Btc-extended signers, + * but is a safety guard for unexpected signer types). + */ +export function useBitcoinSigner() { + const { user } = useCurrentUser(); + + const btcSigner = useMemo((): BtcSigner | null => { + if (!user) return null; + if (hasBtcSigning(user.signer)) return user.signer; + return null; + }, [user]); + + return { + /** Whether the current user's signer supports Bitcoin PSBT signing. */ + canSignPsbt: btcSigner !== null, + /** + * Sign a hex-encoded PSBT. Throws if the signer doesn't support it. + * The returned hex is a signed (but not finalized) PSBT. + */ + signPsbt: btcSigner + ? (psbtHex: string) => btcSigner.signPsbt(psbtHex) + : null, + }; +} diff --git a/src/hooks/useCurrentUser.ts b/src/hooks/useCurrentUser.ts index 9e41530e..f2406472 100644 --- a/src/hooks/useCurrentUser.ts +++ b/src/hooks/useCurrentUser.ts @@ -1,10 +1,12 @@ import { useNostr } from '@nostrify/react'; import { type NLoginType, NUser, useNostrLogin } from '@nostrify/react/login'; -import { NRelay1 } from '@nostrify/nostrify'; +import { NRelay1, NSecSigner } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; import { useCallback, useMemo } from 'react'; import { useAuthor } from './useAuthor.ts'; import { signerWithNudge } from '@/lib/signerWithNudge'; +import { NSecSignerBtc, NBrowserSignerBtc, NConnectSignerBtc } from '@/lib/bitcoin-signers'; export function useCurrentUser() { const { nostr } = useNostr(); @@ -15,23 +17,38 @@ export function useCurrentUser() { let isBunkerConnected: (() => boolean) | undefined; switch (login.type) { - case 'nsec': // Nostr login with secret key - user = NUser.fromNsecLogin(login); + case 'nsec': { // Nostr login with secret key — use BTC-extended signer + const sk = nip19.decode(login.data.nsec) as { type: 'nsec'; data: Uint8Array }; + user = new NUser(login.type, login.pubkey, new NSecSignerBtc(sk.data)); break; - case 'bunker': { // Nostr login with NIP-46 "bunker://" URI - user = NUser.fromBunkerLogin(login, nostr); + } + case 'bunker': { // Nostr login with NIP-46 "bunker://" URI — use BTC-extended signer + const clientSk = nip19.decode(login.data.clientNsec) as { type: 'nsec'; data: Uint8Array }; + const clientSigner = new NSecSigner(clientSk.data); + const bunkerRelays = login.data.relays; + + user = new NUser( + login.type, + login.pubkey, + new NConnectSignerBtc({ + relay: nostr.group(bunkerRelays), + pubkey: login.data.bunkerPubkey, + signer: clientSigner, + timeout: 60_000, + }), + ); + // Called at nudge time to check whether any of the bunker's relay // WebSockets are OPEN. Relay instances are shared with the main pool // so pool.relays will contain them once they have been opened. - const bunkerRelays = (login as Extract).data.relays; isBunkerConnected = () => bunkerRelays.some((url) => { const relay = nostr.relay(url); return relay instanceof NRelay1 && relay.socket.readyState === WebSocket.OPEN; }); break; } - case 'extension': // Nostr login with NIP-07 browser extension - user = NUser.fromExtensionLogin(login); + case 'extension': // Nostr login with NIP-07 browser extension — use BTC-extended signer + user = new NUser(login.type, login.pubkey, new NBrowserSignerBtc()); break; // Other login types can be defined here default: diff --git a/src/lib/bitcoin-signers.ts b/src/lib/bitcoin-signers.ts new file mode 100644 index 00000000..6b1cca29 --- /dev/null +++ b/src/lib/bitcoin-signers.ts @@ -0,0 +1,113 @@ +import type { NostrSigner } from '@nostrify/types'; +import { NSecSigner, NBrowserSigner, NConnectSigner } from '@nostrify/nostrify'; +import type { NConnectSignerOpts } from '@nostrify/nostrify'; + +import { signPsbtLocal } from '@/lib/bitcoin'; + +// --------------------------------------------------------------------------- +// BtcSigner interface +// --------------------------------------------------------------------------- + +/** + * A Nostr signer extended with Bitcoin PSBT signing capability. + * + * Implementations receive a hex-encoded unsigned PSBT, sign all Taproot + * inputs whose `tapInternalKey` matches the signer's key, and return the + * hex-encoded signed (but not finalized) PSBT. + */ +export interface BtcSigner extends NostrSigner { + signPsbt(psbtHex: string): Promise; +} + +/** Runtime check for whether a signer supports `signPsbt`. */ +export function hasBtcSigning(signer: NostrSigner): signer is BtcSigner { + return typeof (signer as BtcSigner).signPsbt === 'function'; +} + +// --------------------------------------------------------------------------- +// NSecSignerBtc — local nsec signing +// --------------------------------------------------------------------------- + +/** + * Extends `NSecSigner` with local Taproot PSBT signing. + * + * `NSecSigner` stores the secret key in a JS `#private` field that subclasses + * cannot access. To work around this, the constructor accepts the raw secret + * key bytes, passes them to `super()`, and keeps its own copy for Bitcoin use. + */ +export class NSecSignerBtc extends NSecSigner implements BtcSigner { + private readonly secretKeyBytes: Uint8Array; + + constructor(secretKey: Uint8Array) { + super(secretKey); + this.secretKeyBytes = new Uint8Array(secretKey); + } + + async signPsbt(psbtHex: string): Promise { + const privateKeyHex = Buffer.from(this.secretKeyBytes).toString('hex'); + return signPsbtLocal(psbtHex, privateKeyHex); + } +} + +// --------------------------------------------------------------------------- +// NBrowserSignerBtc — NIP-07 extension signing +// --------------------------------------------------------------------------- + +/** + * Extends `NBrowserSigner` with NIP-07 `window.nostr.signPsbt()` support. + * + * Calls the extension's `signPsbt` method if available. If the extension does + * not expose `signPsbt`, an error is thrown with a user-friendly message. + */ +export class NBrowserSignerBtc extends NBrowserSigner implements BtcSigner { + constructor(opts?: { timeout?: number }) { + super(opts); + } + + async signPsbt(psbtHex: string): Promise { + // `awaitNostr` is TypeScript-private but JavaScript-public at runtime. + const nostr = await (this as unknown as { awaitNostr(): Promise> }).awaitNostr(); + + if (typeof nostr.signPsbt !== 'function') { + throw new Error( + 'Your browser extension does not support Bitcoin signing (signPsbt). ' + + 'Please use an extension that supports this feature, or log in with your nsec.', + ); + } + + const signPsbt = nostr.signPsbt as (hex: string) => Promise; + return signPsbt(psbtHex); + } +} + +// --------------------------------------------------------------------------- +// NConnectSignerBtc — NIP-46 remote signer +// --------------------------------------------------------------------------- + +/** + * Extends `NConnectSigner` with NIP-46 `sign_psbt` RPC support. + * + * Sends a `sign_psbt` command over the NIP-46 relay channel. The remote + * signer handles the TapTweak and Schnorr signing internally. If the remote + * signer does not support `sign_psbt`, it returns an error which is propagated + * with a user-friendly message. + */ +export class NConnectSignerBtc extends NConnectSigner implements BtcSigner { + constructor(opts: NConnectSignerOpts) { + super(opts); + } + + async signPsbt(psbtHex: string): Promise { + try { + // `cmd` is TypeScript-private but JavaScript-public at runtime. + const cmd = (this as unknown as { cmd(method: string, params: string[]): Promise }).cmd; + return await cmd.call(this, 'sign_psbt', [psbtHex]); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error( + `Remote signer does not support Bitcoin signing (sign_psbt): ${msg}. ` + + 'Please update your signer or log in with your nsec.', + ); + } + } +} diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index 323289e6..7f537806 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -468,35 +468,44 @@ export function maxSendable(totalBalance: number, numInputs: number, feeRate: nu return Math.max(0, totalBalance - fee); } +/** Result of building an unsigned PSBT. */ +export interface UnsignedPsbt { + /** Hex-encoded unsigned PSBT. */ + psbtHex: string; + /** Fee in satoshis. */ + fee: number; +} + /** - * Create, sign, and return a raw Bitcoin Taproot transaction. + * Build an unsigned Taproot PSBT ready for signing. * - * @param privateKeyHex 32-byte hex private key (from Nostr nsec). - * @param toAddress Recipient Bitcoin address. - * @param amountSats Amount to send in satoshis. - * @param utxos Available UTXOs (all will be consumed). - * @param feeRate Fee rate in sat/vB. - * @returns The signed transaction hex and the fee paid. + * This function constructs the PSBT with all inputs and outputs but does NOT + * sign it. The returned hex can be passed to any signer (local nsec, NIP-07 + * extension, or NIP-46 remote signer). + * + * @param senderPubkeyHex 32-byte hex x-only public key of the sender. + * @param toAddress Recipient Bitcoin address. + * @param amountSats Amount to send in satoshis. + * @param utxos Available UTXOs (all will be consumed). + * @param feeRate Fee rate in sat/vB. */ -export function createBitcoinTransaction( - privateKeyHex: string, +export function buildUnsignedPsbt( + senderPubkeyHex: string, toAddress: string, amountSats: number, utxos: UTXO[], feeRate: number, -): { txHex: string; fee: number } { - // 1. Key pair from raw private key - const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex')); - const internalPubkey = toXOnly(keyPair.publicKey); +): UnsignedPsbt { + const internalPubkey = Buffer.from(senderPubkeyHex, 'hex'); - // 2. Derive change address (same Taproot address as sender) + // Derive change address (same Taproot address as sender) const { address: changeAddress } = bitcoin.payments.p2tr({ internalPubkey, network: bitcoin.networks.bitcoin, }); if (!changeAddress) throw new Error('Failed to derive change address'); - // 3. Build PSBT, add all UTXOs as inputs + // Build PSBT, add all UTXOs as inputs const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin }); let totalInput = 0; @@ -516,7 +525,7 @@ export function createBitcoinTransaction( totalInput += utxo.value; } - // 4. Estimate fee — first assume 2 outputs (recipient + change) + // Estimate fee — first assume 2 outputs (recipient + change) const change2Out = totalInput - amountSats - estimateFee(utxos.length, 2, feeRate); const hasChange = change2Out > DUST_LIMIT; const numOutputs = hasChange ? 2 : 1; @@ -529,26 +538,87 @@ export function createBitcoinTransaction( ); } - // 5. Add outputs + // Add outputs psbt.addOutput({ address: toAddress, value: BigInt(amountSats) }); if (hasChange) { psbt.addOutput({ address: changeAddress, value: BigInt(change) }); } - // 6. Tweak private key for Taproot key-path spending (BIP-341) + return { psbtHex: psbt.toHex(), fee }; +} + +/** + * Sign a PSBT locally using a raw private key (nsec). + * + * Applies the BIP-341 TapTweak to the private key, signs all inputs whose + * `tapInternalKey` matches, and returns the signed (but not finalized) PSBT hex. + * + * @param psbtHex Hex-encoded unsigned PSBT. + * @param privateKeyHex 32-byte hex private key. + * @returns Hex-encoded signed PSBT (not finalized). + */ +export function signPsbtLocal(psbtHex: string, privateKeyHex: string): string { + bitcoin.initEccLib(ecc); + const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin }); + + const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex')); + const internalPubkey = toXOnly(keyPair.publicKey); + + // Tweak private key for Taproot key-path spending (BIP-341) const tweakedSigner = keyPair.tweak( bitcoin.crypto.taggedHash('TapTweak', internalPubkey), ); - // 7. Sign all inputs - for (let i = 0; i < utxos.length; i++) { + // Sign all inputs + for (let i = 0; i < psbt.inputCount; i++) { psbt.signInput(i, tweakedSigner); } - // 8. Finalize and extract - psbt.finalizeAllInputs(); - const tx = psbt.extractTransaction(); - - return { txHex: tx.toHex(), fee }; + return psbt.toHex(); +} + +/** + * Finalize a signed PSBT and extract the raw transaction hex. + * + * @param psbtHex Hex-encoded signed PSBT. + * @returns Raw transaction hex ready for broadcast. + */ +export function finalizePsbt(psbtHex: string): string { + bitcoin.initEccLib(ecc); + const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin }); + psbt.finalizeAllInputs(); + return psbt.extractTransaction().toHex(); +} + +/** + * Create, sign, and return a raw Bitcoin Taproot transaction. + * + * Convenience wrapper that calls {@link buildUnsignedPsbt}, + * {@link signPsbtLocal}, and {@link finalizePsbt} in sequence. + * + * @param privateKeyHex 32-byte hex private key (from Nostr nsec). + * @param toAddress Recipient Bitcoin address. + * @param amountSats Amount to send in satoshis. + * @param utxos Available UTXOs (all will be consumed). + * @param feeRate Fee rate in sat/vB. + * @returns The signed transaction hex and the fee paid. + */ +export function createBitcoinTransaction( + privateKeyHex: string, + toAddress: string, + amountSats: number, + utxos: UTXO[], + feeRate: number, +): { txHex: string; fee: number } { + // Derive the x-only pubkey from the private key for buildUnsignedPsbt + const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex')); + const internalPubkey = toXOnly(keyPair.publicKey); + const senderPubkeyHex = Buffer.from(internalPubkey).toString('hex'); + + const { psbtHex, fee } = buildUnsignedPsbt(senderPubkeyHex, toAddress, amountSats, utxos, feeRate); + const signedHex = signPsbtLocal(psbtHex, privateKeyHex); + const txHex = finalizePsbt(signedHex); + + return { txHex, fee }; } diff --git a/src/lib/signerWithNudge.ts b/src/lib/signerWithNudge.ts index 297fd409..6858a75b 100644 --- a/src/lib/signerWithNudge.ts +++ b/src/lib/signerWithNudge.ts @@ -3,6 +3,7 @@ import { createElement } from 'react'; import { toast } from '@/hooks/useToast'; import { androidResume } from '@/lib/androidResume'; import { NudgeToastContent } from '@/components/SignerToastContent'; +import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers'; // --------------------------------------------------------------------------- // Constants @@ -355,5 +356,13 @@ export function signerWithNudge( wrapped.nip44 = wrapCrypto(signer.nip44); } + // Forward signPsbt if the underlying signer supports Bitcoin signing. + if (hasBtcSigning(signer)) { + const btcWrapped = wrapped as BtcSigner; + const btcSigner = signer; + btcWrapped.signPsbt = (psbtHex: string) => + run(() => btcSigner.signPsbt(psbtHex), undefined, 'sign'); + } + return wrapped; } From aadd2908e2dfd290b80bcd3983944a7bfb3a3dbf Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 11 Apr 2026 19:52:21 -0300 Subject: [PATCH 016/330] Add generic route-transition reaction for Blobbi companion On page navigation the companion now briefly pauses and scans the layout areas that changed. Center content is always scanned first, followed by the right sidebar if a non-placeholder sidebar is detected in the DOM. Implementation: - useRouteReaction.ts: thin orchestration hook that watches pathname, determines changed areas, and chains triggerAttention calls via setTimeout. Cancels on new route change, drag, or unmount. - useBlobbiCompanion.ts: wires the new hook with existing triggerAttention/clearAttention from useBlobbiAttention. No changes to the attention system, state machine, gaze hook, motion hook, or entry animation. The existing attending state and attend-ui gaze mode handle all the visual behavior. Includes an empty ROUTE_REACTIONS map for future per-route overrides. --- .../companion/hooks/useBlobbiCompanion.ts | 14 +- .../companion/hooks/useRouteReaction.ts | 283 ++++++++++++++++++ 2 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/blobbi/companion/hooks/useRouteReaction.ts diff --git a/src/blobbi/companion/hooks/useBlobbiCompanion.ts b/src/blobbi/companion/hooks/useBlobbiCompanion.ts index 8dd1f2cc..fc055f95 100644 --- a/src/blobbi/companion/hooks/useBlobbiCompanion.ts +++ b/src/blobbi/companion/hooks/useBlobbiCompanion.ts @@ -36,6 +36,7 @@ import { useBlobbiCompanionMotion } from './useBlobbiCompanionMotion'; import { useBlobbiCompanionGaze } from './useBlobbiCompanionGaze'; import { useBlobbiAttention } from './useBlobbiAttention'; import { useBlobbiEntryAnimation } from './useBlobbiEntryAnimation'; +import { useRouteReaction } from './useRouteReaction'; import { useFeedSettings } from '@/hooks/useFeedSettings'; /** Options for triggering attention */ @@ -189,7 +190,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult { }, [viewport.width, viewport.height]); // Attention management - will be activated after entry completes - const { currentAttention, triggerAttention } = useBlobbiAttention({ + const { currentAttention, triggerAttention, clearAttention } = useBlobbiAttention({ isActive: isVisible && hasEnteredOnce, }); @@ -275,6 +276,17 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult { onStart: handleEntryStart, }); + // Route-change reactions — scan changed layout areas after navigation + useRouteReaction({ + pathname: location.pathname, + triggerAttention, + clearAttention, + hasEnteredOnce, + isEntering, + isDragging: motion.isDragging, + viewport, + }); + // Companion should be hidden during route transition delay const shouldBeVisible = isVisible && !isHiddenForTransition; diff --git a/src/blobbi/companion/hooks/useRouteReaction.ts b/src/blobbi/companion/hooks/useRouteReaction.ts new file mode 100644 index 00000000..599318d0 --- /dev/null +++ b/src/blobbi/companion/hooks/useRouteReaction.ts @@ -0,0 +1,283 @@ +/** + * useRouteReaction Hook + * + * Thin orchestration layer for page-transition reactions. + * + * On route change the companion briefly pauses and scans the layout areas + * that changed (center content first, then right sidebar if present), + * using the existing attention system for each step. + * + * Architecture: + * - Watches pathname for changes (after the initial entry has completed) + * - Determines which layout columns changed + * - Fires a sequence of triggerAttention calls with setTimeout chaining + * - Cancels the sequence on new route change, drag, or higher-priority attention + * + * Future custom reactions: + * Add entries to ROUTE_REACTIONS to override the generic scan for specific + * routes. Each entry receives a context object with triggerAttention and a + * timeouts array (auto-cancelled on next route change). + */ + +import { useEffect, useRef, useCallback } from 'react'; + +import type { Position, AttentionPriority } from '../types/companion.types'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface TriggerAttentionFn { + (position: Position, options?: { + duration?: number; + priority?: AttentionPriority; + source?: string; + isGlance?: boolean; + }): void; +} + +interface UseRouteReactionOptions { + /** Current pathname from useLocation */ + pathname: string; + /** Trigger a single attention target */ + triggerAttention: TriggerAttentionFn; + /** Clear any current attention */ + clearAttention: () => void; + /** Whether the companion has completed its first entry */ + hasEnteredOnce: boolean; + /** Whether entry animation is currently playing */ + isEntering: boolean; + /** Whether the companion is being dragged */ + isDragging: boolean; + /** Current viewport dimensions */ + viewport: { width: number; height: number }; +} + +/** Context passed to custom route-reaction functions. */ +interface RouteReactionContext { + pathname: string; + prevPathname: string; + triggerAttention: TriggerAttentionFn; + clearAttention: () => void; + viewport: { width: number; height: number }; + /** Push timeout IDs here — they are auto-cancelled on next route change. */ + timeouts: ReturnType[]; +} + +type RouteReactionFn = (ctx: RouteReactionContext) => void; + +// ─── Custom Route Reaction Map ──────────────────────────────────────────────── +// +// Add entries here to override the generic scan for specific routes. +// +// Example (not implemented yet): +// '/treasures': (ctx) => { /* special treasure-chest reaction */ }, +// '/blobbi': (ctx) => { /* special blobbi-page reaction */ }, +// + +const ROUTE_REACTIONS: Record = { + // intentionally empty — generic fallback handles all routes for now +}; + +// ─── Timing ─────────────────────────────────────────────────────────────────── + +/** Duration per area in the generic scan sequence (ms) */ +const SCAN_STEP_DURATION = 1200; + +/** Delay before starting the reaction after route change (ms). + * Gives the new page's DOM time to mount. */ +const ROUTE_REACTION_DELAY = 250; + +// ─── Layout helpers ─────────────────────────────────────────────────────────── + +/** Find the center-top of the main content column. */ +function findMainContentPosition(viewport: { width: number; height: number }): Position | null { + const selectors = [ + 'main', + '[role="main"]', + '.main-content', + '#main-content', + ]; + + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el) { + const rect = el.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height * 0.3, 200), + }; + } + } + + // Fallback: center-top of viewport + return { x: viewport.width / 2, y: viewport.height * 0.25 }; +} + +/** + * Find the center-top of a visible right sidebar. + * + * Returns null when: + * - The sidebar is the empty 300px placeholder (no meaningful content) + * - The sidebar is not visible (below xl breakpoint) + * - No sidebar element is found at all + * + * Detection is conservative: we look for the element that MainLayout renders + * *after* the center column, check that it is visible and has children beyond + * a single empty placeholder div. + */ +function findRightSidebarPosition(): Position | null { + // MainLayout renders:
→ LeftSidebar | CenterColumn | RightSidebar + // The right sidebar is the last child of the flex container. + // When a page provides a custom sidebar, it replaces the placeholder entirely. + // The placeholder is:
{/* Overflow: Poll + CW */} @@ -1581,8 +1541,93 @@ export function ComposeBox({
) )} - - + + {/* Inline emoji / GIF / sticker picker panel — rendered outside the + padded content area so it bleeds edge-to-edge. */} + {pickerOpen && ( +
+ {/* Tab bar — pill highlight style for inline mode */} +
+ + + {customEmojis.length > 0 && ( + + )} +
+ {/* Picker content */} + {pickerTab === 'emoji' ? ( +
}> + { + if (selection.type === 'native') { + insertEmoji(selection.emoji); + } else { + insertEmoji(`:${selection.shortcode}:`); + } + }} + /> + + ) : pickerTab === 'stickers' ? ( + { + setContent((prev) => (prev ? prev + '\n' + emoji.url : emoji.url)); + setPickerOpen(false); + expand(); + }} + /> + ) : ( + { + setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url)); + setPickerOpen(false); + expand(); + }} /> + )} + + )} + ); } diff --git a/src/components/EmojiPicker.tsx b/src/components/EmojiPicker.tsx index eb60cc3e..1343d205 100644 --- a/src/components/EmojiPicker.tsx +++ b/src/components/EmojiPicker.tsx @@ -106,9 +106,9 @@ export function EmojiPicker({ onSelect, customEmojis }: EmojiPickerProps) { previewPosition: "none", skinTonePosition: "search", set: "native", - maxFrequentRows: 2, + maxFrequentRows: 1, navPosition: "bottom", - perLine: 8, + dynamicWidth: true, parent: container, // Auto-focus the search input on desktop so users can type immediately. // Disabled on mobile to avoid the virtual keyboard popping up unexpectedly. @@ -134,19 +134,42 @@ export function EmojiPicker({ onSelect, customEmojis }: EmojiPickerProps) { const picker = new Picker(pickerOptions); pickerRef.current = picker; - // Inject style into shadow DOM to remove backdrop-filter blur on the sticky category bar + // Inject overrides into the shadow DOM. + // emoji-mart hardcodes `width: min-content; height: 435px` on :host + // and sets a calculated pixel width on #root. We override both so + // the picker fills its container and matches the app theme. requestAnimationFrame(() => { const shadowRoot = (container.firstChild as HTMLElement)?.shadowRoot; if (shadowRoot) { const style = document.createElement("style"); style.textContent = [ - ".sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: var(--em-color-background) !important; }", + ":host { width: 100% !important; height: 280px !important; min-height: 160px !important; border-radius: 0 !important; box-shadow: none !important; }", + "#root { width: 100% !important; background-color: transparent !important; --sidebar-width: 0px !important; }", + ".scroll { padding-right: var(--padding) !important; }", + ".sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: transparent !important; }", + // Match the app's input style (same as StickerPicker / GifPicker) + ".search input[type='search'] { background-color: hsl(var(--muted) / 0.5) !important; border: 0 !important; border-radius: 0.5rem !important; padding: 0.5rem 2rem 0.5rem 2.2rem !important; height: 36px !important; }", + ".search input[type='search']:focus { box-shadow: 0 0 0 1px hsl(var(--ring)) !important; background-color: hsl(var(--background)) !important; }", + ".search input[type='search']::placeholder { color: hsl(var(--muted-foreground)) !important; opacity: 1 !important; }", + ".search .icon { color: hsl(var(--muted-foreground)) !important; }", "input { font-size: 16px !important; }", - "#nav button { color: rgba(var(--em-rgb-color), .85) !important; }", - "#nav button[aria-selected] { color: rgb(var(--em-rgb-accent)) !important; }", - // Fix SVGs without intrinsic width/height collapsing to 0x0 in custom emoji grid. - // emoji-mart only sets max-width/max-height on , which can't size a dimensionless SVG. - // The lives inside + // Nav — prevent icon clipping from height constraint + "#nav { flex-shrink: 0 !important; overflow: visible !important; }", + "#nav svg, #nav img { overflow: visible !important; }", + "#nav button { color: hsl(var(--muted-foreground)) !important; overflow: visible !important; }", + "#nav button:hover { color: hsl(var(--foreground)) !important; }", + "#nav button[aria-selected] { color: hsl(var(--primary)) !important; }", + "#nav .bar { background-color: hsl(var(--primary)) !important; }", + // Hover state on emoji buttons + ".category button .background { background-color: hsl(var(--muted)) !important; }", + // Scrollbar — hide the custom scrollbar, use native overlay + ".scroll::-webkit-scrollbar { width: 6px !important; }", + ".scroll::-webkit-scrollbar-thumb { background-color: transparent !important; border: 0 !important; border-radius: 9999px !important; }", + ".scroll:hover::-webkit-scrollbar-thumb { background-color: hsl(var(--border)) !important; }", + ".scroll::-webkit-scrollbar-track { background: transparent !important; }", + // Category headers + ".sticky { color: hsl(var(--muted-foreground)) !important; font-size: 11px !important; text-transform: uppercase !important; letter-spacing: 0.05em !important; }", + // Fix SVGs without intrinsic dimensions collapsing in custom emoji grid ".emoji-mart-emoji img[src] { width: 1em; height: 1em; object-fit: contain; }", ].join(" "); shadowRoot.appendChild(style); @@ -167,7 +190,7 @@ export function EmojiPicker({ onSelect, customEmojis }: EmojiPickerProps) { return (
{ // Prevent scroll from bubbling to the page diff --git a/src/components/GifPicker.tsx b/src/components/GifPicker.tsx index 18d03062..46f4ae9b 100644 --- a/src/components/GifPicker.tsx +++ b/src/components/GifPicker.tsx @@ -118,7 +118,7 @@ export function GifPicker({ onSelect }: GifPickerProps) { }, [onSelect]); return ( -
+
{/* Search input */}
@@ -128,9 +128,9 @@ export function GifPicker({ onSelect }: GifPickerProps) { value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search GIFs..." - className="pl-8 pr-8 h-9 text-base md:text-sm bg-muted/50 border-0 rounded-lg" + className="pl-8 pr-20 h-9 text-base md:text-sm bg-muted/50 border-0 rounded-lg" /> - {query && ( + {query ? ( + ) : ( + + Powered by Tenor + )}
@@ -182,23 +186,7 @@ export function GifPicker({ onSelect }: GifPickerProps) { )} - - {/* Tenor attribution */} -
- Powered by - -
); } -/** Tenor brand wordmark for required attribution. */ -function TenorLogo() { - return ( - - - Tenor - - - ); -} diff --git a/src/components/ReplyComposeModal.tsx b/src/components/ReplyComposeModal.tsx index 14745e00..a2b117fa 100644 --- a/src/components/ReplyComposeModal.tsx +++ b/src/components/ReplyComposeModal.tsx @@ -50,81 +50,30 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu setPortalContainer(node ?? undefined); }, []); - // Prevent the compose modal from closing when the user interacts with a - // nested dialog (e.g. the emoji/GIF picker). On mobile it is very easy to - // tap the emoji picker overlay and accidentally dismiss the compose modal, - // losing the draft. We detect nested-dialog interactions by checking - // whether the click target lives inside another Radix Dialog portal that - // sits above this modal. - const isNestedDialogInteraction = useCallback((e: Event) => { - const target = e.target as HTMLElement | null; - if (!target) return false; - // Radix Dialog overlays have data-state and sit inside [role="dialog"] - // portals. If the target is inside a dialog element that is NOT our own - // DialogContent, a nested dialog is open. - const closestDialog = target.closest('[role="dialog"]'); - if (closestDialog && portalContainer && closestDialog !== portalContainer) { - return true; - } - // Also catch clicks on the overlay itself (data-radix-dialog-overlay or - // the backdrop element) that belongs to a nested dialog. - const closestOverlay = target.closest('[data-radix-dialog-overlay]'); - if (closestOverlay) { - // Check if this overlay belongs to our dialog or a nested one. - // Our overlay is a sibling of our DialogContent, not a descendant. - // If the overlay is rendered inside our portal container's parent - // (the same portal), it could be ours. But if there are multiple - // overlays, the topmost (last in DOM) belongs to the nested dialog. - const allOverlays = document.querySelectorAll('[data-radix-dialog-overlay]'); - if (allOverlays.length > 1) { - return true; - } - } - return false; - }, [portalContainer]); - + // Always prevent closing the compose modal by clicking the backdrop overlay. + // Users must explicitly close via the X button or Escape key. This prevents + // accidental content loss from stray taps on mobile or misclicks. const handleInteractOutside = useCallback((e: Event) => { - if (isNestedDialogInteraction(e)) { - e.preventDefault(); - return; - } - // The emoji/mention autocomplete dropdowns are portaled to document.body - // (outside the Dialog DOM tree) to escape overflow clipping. Clicks on - // them fire as "interact outside" the dialog — prevent dismissal so the - // user can select an emoji or mention with the mouse. - const target = e.target as HTMLElement | null; - if (target?.closest('[data-autocomplete-dropdown]')) { - e.preventDefault(); - } - }, [isNestedDialogInteraction]); - - const handleEscapeKeyDown = useCallback((e: KeyboardEvent) => { - // When a nested dialog is open, Radix will close it first via its own - // handler. But the escape event can bubble and also close the parent - // modal. We prevent that by checking if any nested dialog is currently - // open (any dialog with data-state="open" that is not ours). - const openDialogs = document.querySelectorAll('[role="dialog"][data-state="open"]'); - const hasNestedDialog = Array.from(openDialogs).some( - (el) => portalContainer && el !== portalContainer, - ); - if (hasNestedDialog) { - e.preventDefault(); - } - }, [portalContainer]); + e.preventDefault(); + }, []); return ( { + // Prevent Radix from focusing its own first-focusable (the X button). e.preventDefault(); - const target = e.target as HTMLElement; - const textarea = target.querySelector('textarea'); - textarea?.focus(); + // Immediately focus the textarea — this MUST happen synchronously + // inside this handler so iOS treats it as part of the original user + // gesture and raises the virtual keyboard. + const textarea = (e.currentTarget as HTMLElement).querySelector('textarea'); + if (textarea) { + textarea.focus(); + } }} onInteractOutside={handleInteractOutside} - onEscapeKeyDown={handleEscapeKeyDown} > {/* Header */} @@ -171,9 +120,10 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
- {/* Embedded original post (reply only, not for quotes) */} + {/* Embedded original post (reply only, not for quotes) + Capped at 20% of viewport so it never dominates the modal. */} {event && !isQuote && ( -
+
{isUrl ? (
@@ -186,7 +136,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu {/* Bluesky disclaimer */} {isUrl && /bsky\.(app|social)/.test(event.href) && ( -
+
⚠️

People on Bluesky can't see you because they're not actually decentralized. @@ -194,8 +144,8 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu

)} - {/* Compose area */} -
+ {/* Compose area — takes remaining space; ComposeBox handles its own scroll */} +
void; + /** Fixed height for the picker. Defaults to 350px. */ + height?: number; + /** Auto-focus the search input on mount (default true on desktop). */ + autoFocus?: boolean; +} + +export function StickerPicker({ customEmojis, onSelect, height = 350, autoFocus = true }: StickerPickerProps) { + const [query, setQuery] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (autoFocus) { + const timer = setTimeout(() => inputRef.current?.focus(), 100); + return () => clearTimeout(timer); + } + }, [autoFocus]); + + const filtered = useMemo(() => { + if (!query.trim()) return customEmojis; + const q = query.toLowerCase(); + return customEmojis.filter((e) => e.shortcode.toLowerCase().includes(q)); + }, [customEmojis, query]); + + if (customEmojis.length === 0) { + return ( +
+ +

No sticker packs yet

+

Add emoji packs to your profile to use stickers

+
+ ); + } + + return ( +
+ {/* Search input */} +
+
+ + setQuery(e.target.value)} + placeholder="Search stickers..." + className="pl-8 pr-8 h-9 text-base md:text-sm bg-muted/50 border-0 rounded-lg" + /> + {query && ( + + )} +
+
+ + {/* Results */} + + {filtered.length === 0 ? ( +
+

No stickers found

+

Try a different search term

+
+ ) : ( +
+ {filtered.map((emoji) => ( + + ))} +
+ )} +
+
+ ); +} From 52e42fcd6ebf3e9e175da5bdcda00a566eb93960 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 11:01:04 -0500 Subject: [PATCH 028/330] Replace hardcoded 'Ditto' with appConfig.appName and appConfig.appId User-facing display strings now read from config.appName so forks can rebrand without code changes, and localStorage keys are namespaced by config.appId so forks running on the same origin don't clobber each other's preferences. Module-level cache-key constants that previously hardcoded 'ditto:' have been refactored into hook-scoped reads from config.appId (via a new getStorageKey() helper). The helpContent FAQ template now uses {appName} placeholders substituted at read-time through getFAQCategories(appName)/getFAQItem(appName, id). --- src/components/ContentSettings.tsx | 41 +++++---- src/components/DittoLogo.tsx | 9 +- src/components/Feed.tsx | 13 +-- src/components/HelpFAQSection.tsx | 9 +- src/components/HelpTip.tsx | 4 +- src/components/MuteListRecoveryDialog.tsx | 4 +- src/components/NostrSync.tsx | 21 +++-- src/components/NsitePreviewDialog.tsx | 2 +- src/components/VersionCheck.tsx | 13 +-- src/components/WeatherStationCard.tsx | 5 +- src/components/WebxdcEmbed.tsx | 4 +- .../letter/LetterPreferencesSection.tsx | 4 +- src/components/letter/StationeryPicker.tsx | 6 +- src/hooks/useCuratorFollowList.ts | 17 ++-- src/hooks/useFeed.ts | 7 +- src/hooks/useFeedTab.ts | 7 +- src/hooks/useFollowActions.ts | 19 ++-- src/hooks/useInitialSync.ts | 15 ++-- src/hooks/useMuteList.ts | 26 +++--- src/lib/helpContent.ts | 88 ++++++++++++++----- src/lib/iframeSubdomain.ts | 16 ++-- src/lib/storageKey.ts | 16 ++++ src/pages/AIChatPage.tsx | 15 ++-- src/pages/CSAEPolicyPage.tsx | 18 ++-- src/pages/FollowPage.tsx | 7 +- src/pages/HelpPage.tsx | 2 +- src/pages/ProfilePage.tsx | 5 +- 27 files changed, 251 insertions(+), 142 deletions(-) create mode 100644 src/lib/storageKey.ts diff --git a/src/components/ContentSettings.tsx b/src/components/ContentSettings.tsx index caad70c9..ef0b1870 100644 --- a/src/components/ContentSettings.tsx +++ b/src/components/ContentSettings.tsx @@ -29,6 +29,7 @@ import { buildKindOptions } from '@/lib/feedFilterUtils'; import { genUserName } from '@/lib/genUserName'; import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds'; import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems'; +import { getStorageKey } from '@/lib/storageKey'; import type { SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext'; import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds'; @@ -246,43 +247,44 @@ function FeedTabsSection() { const { toast } = useToast(); const { updateSettings } = useEncryptedSettings(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { feedSettings, updateFeedSettings } = useFeedSettings(); const [communityDomain, setCommunityDomain] = useState(''); const [isDownloading, setIsDownloading] = useState(false); const [community, setCommunity] = useState<{ domain: string; userCount: number; label: string } | null>(() => { - const stored = localStorage.getItem('ditto:community'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'community')); return stored ? JSON.parse(stored) : null; }); const [showDittoFeed, setShowDittoFeed] = useState(() => { - const stored = localStorage.getItem('ditto:showDittoFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showDittoFeed')); return stored !== null ? stored === 'true' : true; // Default to true }); const [showGlobalFeed, setShowGlobalFeed] = useState(() => { - const stored = localStorage.getItem('ditto:showGlobalFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showGlobalFeed')); return stored !== null ? stored === 'true' : false; // Default to false }); const [showCommunityFeed, setShowCommunityFeed] = useState(() => { - const stored = localStorage.getItem('ditto:showCommunityFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showCommunityFeed')); return stored !== null ? stored === 'true' : false; // Default to false }); const handleToggleDittoFeed = async (checked: boolean) => { setShowDittoFeed(checked); - localStorage.setItem('ditto:showDittoFeed', String(checked)); + localStorage.setItem(getStorageKey(config.appId, 'showDittoFeed'), String(checked)); toast({ - title: checked ? 'Ditto feed enabled' : 'Ditto feed disabled', + title: checked ? `${config.appName} feed enabled` : `${config.appName} feed disabled`, description: checked - ? 'The Ditto feed tab will appear in your navigation' - : 'The Ditto feed tab will be hidden', + ? `The ${config.appName} feed tab will appear in your navigation` + : `The ${config.appName} feed tab will be hidden`, }); }; const handleToggleGlobalFeed = async (checked: boolean) => { setShowGlobalFeed(checked); - localStorage.setItem('ditto:showGlobalFeed', String(checked)); + localStorage.setItem(getStorageKey(config.appId, 'showGlobalFeed'), String(checked)); if (user) { await updateSettings.mutateAsync({ showGlobalFeed: checked }); } @@ -296,7 +298,7 @@ function FeedTabsSection() { const handleToggleCommunityFeed = async (checked: boolean) => { setShowCommunityFeed(checked); - localStorage.setItem('ditto:showCommunityFeed', String(checked)); + localStorage.setItem(getStorageKey(config.appId, 'showCommunityFeed'), String(checked)); if (user) { await updateSettings.mutateAsync({ showCommunityFeed: checked }); } @@ -351,14 +353,14 @@ function FeedTabsSection() { // Store in localStorage (single community only) const newCommunity = { domain, userCount, label }; setCommunity(newCommunity); - localStorage.setItem('ditto:community', JSON.stringify(newCommunity)); + localStorage.setItem(getStorageKey(config.appId, 'community'), JSON.stringify(newCommunity)); // Store the actual JSON data for later use - localStorage.setItem('ditto:communityData', JSON.stringify(data)); + localStorage.setItem(getStorageKey(config.appId, 'communityData'), JSON.stringify(data)); // Auto-enable the Community feed tab setShowCommunityFeed(true); - localStorage.setItem('ditto:showCommunityFeed', 'true'); + localStorage.setItem(getStorageKey(config.appId, 'showCommunityFeed'), 'true'); // Sync to encrypted settings if (user) { @@ -388,12 +390,12 @@ function FeedTabsSection() { const handleRemoveCommunity = async () => { setCommunity(null); - localStorage.removeItem('ditto:community'); - localStorage.removeItem('ditto:communityData'); + localStorage.removeItem(getStorageKey(config.appId, 'community')); + localStorage.removeItem(getStorageKey(config.appId, 'communityData')); // Also disable the community feed tab setShowCommunityFeed(false); - localStorage.setItem('ditto:showCommunityFeed', 'false'); + localStorage.setItem(getStorageKey(config.appId, 'showCommunityFeed'), 'false'); if (user) { await updateSettings.mutateAsync({ communityData: undefined, showCommunityFeed: false }); @@ -447,8 +449,8 @@ function FeedTabsSection() {
- -

Show trending and curated content from the Ditto relay

+ +

Show trending and curated content from the {config.appName} relay

setNewHashtag(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }} diff --git a/src/components/DittoLogo.tsx b/src/components/DittoLogo.tsx index 6bd03cb8..26eeb462 100644 --- a/src/components/DittoLogo.tsx +++ b/src/components/DittoLogo.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; +import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { cn } from '@/lib/utils'; @@ -107,9 +108,10 @@ function getPixelArtMask(): Promise { return maskPromise; } -/** The Ditto logo rendered from the custom SVG asset. Occasionally appears pixelated for logged-in users. */ +/** The app logo rendered from the custom SVG asset. Occasionally appears pixelated for logged-in users. */ export function DittoLogo({ className, size = 40 }: DittoLogoProps) { const { user } = useCurrentUser(); + const { config } = useAppContext(); if (isPixelated && user) { return ; @@ -118,7 +120,7 @@ export function DittoLogo({ className, size = 40 }: DittoLogoProps) { return (
(null); const [ready, setReady] = useState(!!maskCache); + const { config } = useAppContext(); useEffect(() => { const el = ref.current; @@ -164,7 +167,7 @@ function PixelatedLogo({ className, size = 40 }: DittoLogoProps) {
{ - const stored = localStorage.getItem('ditto:showGlobalFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showGlobalFeed')); return stored !== null ? stored === 'true' : false; })(); const showDittoFeed = (() => { - const stored = localStorage.getItem('ditto:showDittoFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showDittoFeed')); return stored !== null ? stored === 'true' : true; })(); const showCommunityFeed = (() => { - const stored = localStorage.getItem('ditto:showCommunityFeed'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'showCommunityFeed')); return stored !== null ? stored === 'true' : false; })(); const communityLabel = (() => { try { - const stored = localStorage.getItem('ditto:community'); + const stored = localStorage.getItem(getStorageKey(config.appId, 'community')); if (stored) { const community = JSON.parse(stored); return community.label || 'Community'; @@ -247,7 +250,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee handleSetActiveTab('follows')} /> {!isKindSpecificPage && showDittoFeed && ( - handleSetActiveTab('ditto')} /> + handleSetActiveTab('ditto')} /> )} {!isKindSpecificPage && showCommunityFeed && ( handleSetActiveTab('communities')} /> diff --git a/src/components/HelpFAQSection.tsx b/src/components/HelpFAQSection.tsx index 3f2d5cad..c67b9677 100644 --- a/src/components/HelpFAQSection.tsx +++ b/src/components/HelpFAQSection.tsx @@ -6,7 +6,8 @@ import { AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; -import { FAQ_CATEGORIES, type FAQCategory, type FAQItem } from '@/lib/helpContent'; +import { useAppContext } from '@/hooks/useAppContext'; +import { getFAQCategories, type FAQCategory, type FAQItem } from '@/lib/helpContent'; // ── Inline markup renderer ──────────────────────────────────────────────────── @@ -87,8 +88,10 @@ interface HelpFAQSectionProps { * */ export function HelpFAQSection({ categories, items, hideHeadings, className }: HelpFAQSectionProps) { + const { config } = useAppContext(); + const filteredCategories = useMemo(() => { - let cats: FAQCategory[] = FAQ_CATEGORIES; + let cats: FAQCategory[] = getFAQCategories(config.appName); // Filter to specific categories if (categories) { @@ -106,7 +109,7 @@ export function HelpFAQSection({ categories, items, hideHeadings, className }: H } return cats; - }, [categories, items]); + }, [categories, items, config.appName]); if (filteredCategories.length === 0) return null; diff --git a/src/components/HelpTip.tsx b/src/components/HelpTip.tsx index a7317adc..b9c3ceb4 100644 --- a/src/components/HelpTip.tsx +++ b/src/components/HelpTip.tsx @@ -2,6 +2,7 @@ import { HelpCircle } from 'lucide-react'; import { Link } from 'react-router-dom'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useAppContext } from '@/hooks/useAppContext'; import { getFAQItem } from '@/lib/helpContent'; /** @@ -62,7 +63,8 @@ interface HelpTipProps { * */ export function HelpTip({ faqId, iconSize = 'size-4', className }: HelpTipProps) { - const item = getFAQItem(faqId); + const { config } = useAppContext(); + const item = getFAQItem(config.appName, faqId); if (!item) return null; return ( diff --git a/src/components/MuteListRecoveryDialog.tsx b/src/components/MuteListRecoveryDialog.tsx index 91fd2713..b750d83f 100644 --- a/src/components/MuteListRecoveryDialog.tsx +++ b/src/components/MuteListRecoveryDialog.tsx @@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { ScrollArea } from '@/components/ui/scroll-area'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; +import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; @@ -302,6 +303,7 @@ function SnapshotSkeleton() { function MuteHistoryContent({ onClose }: { onClose: () => void }) { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { mutateAsync: publishEvent } = useNostrPublish(); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -369,7 +371,7 @@ function MuteHistoryContent({ onClose }: { onClose: () => void }) { // Update the local mute cache with the restored items const summary = summaries?.get(event.id); if (summary && user) { - setCachedMuteItems(user.pubkey, summary.items); + setCachedMuteItems(config.appId, user.pubkey, summary.items); } toast({ diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index 748022b3..5ceb9f4f 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -7,6 +7,7 @@ import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useEncryptedSettings, setLocalSettingsSync } from "@/hooks/useEncryptedSettings"; import { isSyncDone } from "@/hooks/useInitialSync"; import { parseBlossomServerList } from "@/lib/appBlossom"; +import { getStorageKey } from "@/lib/storageKey"; import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent"; import type { ThemeConfig } from "@/themes"; @@ -213,7 +214,7 @@ export function NostrSync() { // Only reset theme/sidebar for real account switches, not fresh signups. // During signup, isSyncDone returns false and the onboarding // questionnaire owns theme state until it saves settings. - if (isSyncDone(user.pubkey)) { + if (isSyncDone(config.appId, user.pubkey)) { updateConfig((current) => { let changed = false; const updates = { ...current }; @@ -402,17 +403,19 @@ export function NostrSync() { // Sync feed tab settings (stored directly in localStorage, not AppConfig) if (encryptedSettings.showGlobalFeed !== undefined) { - const current = localStorage.getItem("ditto:showGlobalFeed"); + const key = getStorageKey(config.appId, "showGlobalFeed"); + const current = localStorage.getItem(key); const incoming = String(encryptedSettings.showGlobalFeed); if (current !== incoming) { - localStorage.setItem("ditto:showGlobalFeed", incoming); + localStorage.setItem(key, incoming); } } if (encryptedSettings.showCommunityFeed !== undefined) { - const current = localStorage.getItem("ditto:showCommunityFeed"); + const key = getStorageKey(config.appId, "showCommunityFeed"); + const current = localStorage.getItem(key); const incoming = String(encryptedSettings.showCommunityFeed); if (current !== incoming) { - localStorage.setItem("ditto:showCommunityFeed", incoming); + localStorage.setItem(key, incoming); } } if (encryptedSettings.communityData) { @@ -421,12 +424,13 @@ export function NostrSync() { label: encryptedSettings.communityData.label, userCount: encryptedSettings.communityData.userCount, }; - const currentRaw = localStorage.getItem("ditto:community"); + const communityKey = getStorageKey(config.appId, "community"); + const currentRaw = localStorage.getItem(communityKey); const incoming = JSON.stringify(community); if (currentRaw !== incoming) { - localStorage.setItem("ditto:community", incoming); + localStorage.setItem(communityKey, incoming); localStorage.setItem( - "ditto:communityData", + getStorageKey(config.appId, "communityData"), JSON.stringify({ names: encryptedSettings.communityData.nip05 }), ); } @@ -444,6 +448,7 @@ export function NostrSync() { updateConfig, recentlyWritten, seededTimestamp, + config.appId, ]); // Sync active profile theme (kind 16767) on pageload when autoShareTheme is enabled. diff --git a/src/components/NsitePreviewDialog.tsx b/src/components/NsitePreviewDialog.tsx index fbe5dd21..23f8a4d5 100644 --- a/src/components/NsitePreviewDialog.tsx +++ b/src/components/NsitePreviewDialog.tsx @@ -202,7 +202,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha // a private HMAC-SHA256 subdomain so the raw identifier is never exposed as // a sandbox origin (preventing cross-app localStorage/IndexedDB collisions). const nsiteSubdomain = getNsiteSubdomain(event); - const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]); + const previewSubdomain = useMemo(() => deriveIframeSubdomain(config.appId, 'nsite', nsiteSubdomain), [config.appId, nsiteSubdomain]); // Build the manifest and server list from the event (memoised per event identity) const manifest = useRef>(new Map()); diff --git a/src/components/VersionCheck.tsx b/src/components/VersionCheck.tsx index e7fd9b17..113dcc86 100644 --- a/src/components/VersionCheck.tsx +++ b/src/components/VersionCheck.tsx @@ -3,9 +3,9 @@ import { Link } from 'react-router-dom'; import { toast } from '@/hooks/useToast'; import { ToastAction } from '@/components/ui/toast'; +import { useAppContext } from '@/hooks/useAppContext'; import { parseChangelog } from '@/lib/changelog'; - -const STORAGE_KEY = 'ditto:app-version'; +import { getStorageKey } from '@/lib/storageKey'; /** Fetch the first changelog item for the given version (or the latest entry). */ async function fetchChangelogExcerpt(version: string): Promise { @@ -31,12 +31,15 @@ async function fetchChangelogExcerpt(version: string): Promise { const currentVersion = import.meta.env.VERSION; if (!currentVersion) return; - const storedVersion = localStorage.getItem(STORAGE_KEY); - localStorage.setItem(STORAGE_KEY, currentVersion); + const storageKey = getStorageKey(config.appId, 'app-version'); + const storedVersion = localStorage.getItem(storageKey); + localStorage.setItem(storageKey, currentVersion); if (storedVersion && storedVersion !== currentVersion) { // Show the toast immediately, then enrich it with a changelog excerpt. @@ -64,7 +67,7 @@ export function VersionCheck() { } }); } - }, []); + }, [config.appId]); return null; } diff --git a/src/components/WeatherStationCard.tsx b/src/components/WeatherStationCard.tsx index 8c4de608..091dfc0d 100644 --- a/src/components/WeatherStationCard.tsx +++ b/src/components/WeatherStationCard.tsx @@ -10,9 +10,11 @@ import { Wind, } from 'lucide-react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useAppContext } from '@/hooks/useAppContext'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useWeatherStation } from '@/hooks/useWeatherStation'; import { timeAgo } from '@/lib/timeAgo'; +import { getStorageKey } from '@/lib/storageKey'; import { formatWeatherSensorValue, parseWeatherStationRef, type WeatherUnitSystem } from '@/lib/weatherStation'; function weatherSensorIcon(sensorKey: string) { @@ -34,9 +36,10 @@ interface WeatherStationCardProps { } export function WeatherStationCard({ value, compact = false }: WeatherStationCardProps) { + const { config } = useAppContext(); const stationRef = parseWeatherStationRef(value); const { data, isPending } = useWeatherStation(value); - const [units, setUnits] = useLocalStorage('ditto:weather-units', 'normal'); + const [units, setUnits] = useLocalStorage(getStorageKey(config.appId, 'weather-units'), 'normal'); if (!stationRef) { return ( diff --git a/src/components/WebxdcEmbed.tsx b/src/components/WebxdcEmbed.tsx index 0ae5491c..6f3d0f79 100644 --- a/src/components/WebxdcEmbed.tsx +++ b/src/components/WebxdcEmbed.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Webxdc, type WebxdcHandle } from '@/components/Webxdc'; import { GameControls } from '@/components/GameControls'; import { useCenterColumn } from '@/contexts/LayoutContext'; +import { useAppContext } from '@/hooks/useAppContext'; import { useWebxdc } from '@/hooks/useWebxdc'; import { deriveIframeSubdomain } from '@/lib/iframeSubdomain'; import { cn } from '@/lib/utils'; @@ -57,10 +58,11 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro const centerColumn = useCenterColumn(); const columnRect = useElementRect(launched ? centerColumn : null); + const { config } = useAppContext(); // Derive a private, stable subdomain from a device-local seed + the identifier. const identifier = uuid ?? url; - const iframeId = deriveIframeSubdomain('webxdc', identifier); + const iframeId = deriveIframeSubdomain(config.appId, 'webxdc', identifier); const handleClose = useCallback(() => { setLaunched(false); diff --git a/src/components/letter/LetterPreferencesSection.tsx b/src/components/letter/LetterPreferencesSection.tsx index d0161c84..7972e1ef 100644 --- a/src/components/letter/LetterPreferencesSection.tsx +++ b/src/components/letter/LetterPreferencesSection.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { ArrowLeft, Sparkles, RotateCcw } from 'lucide-react'; import { SubHeaderBar } from '@/components/SubHeaderBar'; +import { useAppContext } from '@/hooks/useAppContext'; import { useLetterPreferences } from '@/hooks/useLetterPreferences'; import { useThemeStationery } from '@/hooks/useThemeStationery'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -22,6 +23,7 @@ function toSerializable(s: Stationery): Stationery { export function LetterPreferencesSection() { const { user } = useCurrentUser(); + const { config } = useAppContext(); const navigate = useNavigate(); const { prefs, updatePrefs, resetStationery, isThemeDefault } = useLetterPreferences(); const themeStationery = useThemeStationery(); @@ -122,7 +124,7 @@ export function LetterPreferencesSection() { Using your{' '} - Ditto theme + {config.appName} theme {' '}as stationery diff --git a/src/components/letter/StationeryPicker.tsx b/src/components/letter/StationeryPicker.tsx index 5972d354..8e93617a 100644 --- a/src/components/letter/StationeryPicker.tsx +++ b/src/components/letter/StationeryPicker.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { Skeleton } from '@/components/ui/skeleton'; import { Switch } from '@/components/ui/switch'; +import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { Dialog, @@ -244,6 +245,7 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps) const [infoOpen, setInfoOpen] = useState(false); const { user } = useCurrentUser(); + const { config } = useAppContext(); const followListData = useFollowList(); const followPubkeyArray = followListData.data?.pubkeys; const followList = useMemo(() => new Set(followPubkeyArray ?? []), [followPubkeyArray]); @@ -410,11 +412,11 @@ export function StationeryPicker({ selected, onSelect }: StationeryPickerProps) {isThemesTab && ( <> - Ditto themes + {config.appName} themes

- Ditto themes are UI themes shared by the community. Letters borrows their colors and fonts to style your letter. + {config.appName} themes are UI themes shared by the community. Letters borrows their colors and fonts to style your letter.

({ queryKey: ['curator-follow-list', curatorPubkey], @@ -57,12 +56,12 @@ export function useCuratorFollowList() { // Include the curator themselves const allPubkeys = [...new Set([curatorPubkey, ...pubkeys])]; - setCached(allPubkeys); + setCached(cacheKey, allPubkeys); return allPubkeys; }, enabled: !!curatorPubkey, staleTime: 10 * 60 * 1000, // 10 minutes gcTime: 60 * 60 * 1000, // 1 hour - placeholderData: getCached(), + placeholderData: getCached(cacheKey), }); } diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts index db403046..83db5f00 100644 --- a/src/hooks/useFeed.ts +++ b/src/hooks/useFeed.ts @@ -1,5 +1,6 @@ import { useNostr } from '@nostrify/react'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { useAppContext } from './useAppContext'; import { useCurrentUser } from './useCurrentUser'; import { useFeedSettings } from './useFeedSettings'; import { useFollowList } from './useFollowActions'; @@ -8,6 +9,7 @@ import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { getPaginationCursor, parseRepostContent, isRepostKind, type FeedItem } from '@/lib/feedUtils'; import { isReplyEvent } from '@/lib/nostrEvents'; import { setProfileCached } from '@/lib/profileCache'; +import { getStorageKey } from '@/lib/storageKey'; import type { NostrEvent } from '@nostrify/nostrify'; const PAGE_SIZE = 15; @@ -43,6 +45,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use const { nostr } = useNostr(); const queryClient = useQueryClient(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { data: followData } = useFollowList(); const followList = followData?.pubkeys; const { feedSettings } = useFeedSettings(); @@ -65,7 +68,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use const communityPubkeys = (() => { if (tab !== 'communities') return []; try { - const dataStr = localStorage.getItem('ditto:communityData'); + const dataStr = localStorage.getItem(getStorageKey(config.appId, 'communityData')); if (!dataStr) return []; const data = JSON.parse(dataStr); @@ -116,7 +119,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use // Get the community domain for verification let communityDomain = ''; try { - const communityStr = localStorage.getItem('ditto:community'); + const communityStr = localStorage.getItem(getStorageKey(config.appId, 'community')); if (communityStr) { const community = JSON.parse(communityStr); communityDomain = community.domain; diff --git a/src/hooks/useFeedTab.ts b/src/hooks/useFeedTab.ts index 46fc00e9..e987c123 100644 --- a/src/hooks/useFeedTab.ts +++ b/src/hooks/useFeedTab.ts @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react'; +import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; - -const STORAGE_PREFIX = 'ditto:feed-tab:'; +import { getStorageKey } from '@/lib/storageKey'; /** * Manages the active feed tab for a specific feed page, persisting @@ -18,7 +18,8 @@ export function useFeedTab( validTabs?: readonly T[], ): [T, (tab: T) => void] { const { user } = useCurrentUser(); - const key = STORAGE_PREFIX + feedId; + const { config } = useAppContext(); + const key = getStorageKey(config.appId, `feed-tab:${feedId}`); const [activeTab, setActiveTab] = useState(() => { const defaultTab = (user ? 'follows' : 'ditto') as T; diff --git a/src/hooks/useFollowActions.ts b/src/hooks/useFollowActions.ts index 31731bf5..4d188169 100644 --- a/src/hooks/useFollowActions.ts +++ b/src/hooks/useFollowActions.ts @@ -3,7 +3,9 @@ import { useNostr } from '@nostrify/react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; +import { useAppContext } from './useAppContext'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { getStorageKey } from '@/lib/storageKey'; import type { NostrEvent } from '@nostrify/nostrify'; // --------------------------------------------------------------------------- @@ -17,13 +19,10 @@ export interface FollowListData { pubkeys: string[]; } -/** localStorage key for cached follow list pubkeys. */ -const FOLLOW_CACHE_KEY = 'ditto:followListCache'; - /** Read cached follow pubkeys from localStorage for a given user. */ -function getCachedFollowList(pubkey: string): FollowListData | undefined { +function getCachedFollowList(cacheKey: string, pubkey: string): FollowListData | undefined { try { - const raw = localStorage.getItem(FOLLOW_CACHE_KEY); + const raw = localStorage.getItem(cacheKey); if (!raw) return undefined; const cached = JSON.parse(raw); // Only use cache if it belongs to the same user @@ -35,9 +34,9 @@ function getCachedFollowList(pubkey: string): FollowListData | undefined { } /** Persist follow pubkeys to localStorage. */ -function setCachedFollowList(pubkey: string, pubkeys: string[]): void { +function setCachedFollowList(cacheKey: string, pubkey: string, pubkeys: string[]): void { try { - localStorage.setItem(FOLLOW_CACHE_KEY, JSON.stringify({ pubkey, pubkeys })); + localStorage.setItem(cacheKey, JSON.stringify({ pubkey, pubkeys })); } catch { // Storage full or unavailable — non-critical } @@ -54,6 +53,8 @@ function setCachedFollowList(pubkey: string, pubkeys: string[]): void { export function useFollowList() { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { config } = useAppContext(); + const cacheKey = getStorageKey(config.appId, 'followListCache'); return useQuery({ queryKey: ['follow-list', user?.pubkey ?? ''], @@ -67,12 +68,12 @@ export function useFollowList() { const pubkeys = event.tags .filter(([name]) => name === 'p') .map(([, pk]) => pk); - setCachedFollowList(user.pubkey, pubkeys); + setCachedFollowList(cacheKey, user.pubkey, pubkeys); return { event, pubkeys }; }, enabled: !!user, staleTime: 5 * 60 * 1000, - placeholderData: user ? getCachedFollowList(user.pubkey) : undefined, + placeholderData: user ? getCachedFollowList(cacheKey, user.pubkey) : undefined, }); } diff --git a/src/hooks/useInitialSync.ts b/src/hooks/useInitialSync.ts index 4a0144d5..6196f580 100644 --- a/src/hooks/useInitialSync.ts +++ b/src/hooks/useInitialSync.ts @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { parseBlossomServerList } from "@/lib/appBlossom"; import { EncryptedSettingsSchema } from "@/lib/schemas"; +import { getStorageKey } from "@/lib/storageKey"; import { useAppContext } from "./useAppContext"; import { useCurrentUser } from "./useCurrentUser"; import { type EncryptedSettings, setLocalSettingsSync } from "./useEncryptedSettings"; @@ -34,9 +35,9 @@ const SYNC_TIMEOUT_MS = 8000; * Uses a localStorage flag so the sync screen only shows once per user * (not on every page refresh or new session while logged in). */ -export function isSyncDone(pubkey: string): boolean { +export function isSyncDone(appId: string, pubkey: string): boolean { try { - return localStorage.getItem(`ditto:sync-done:${pubkey}`) === "1"; + return localStorage.getItem(getStorageKey(appId, `sync-done:${pubkey}`)) === "1"; } catch { return false; } @@ -52,7 +53,7 @@ export function useInitialSync() { // for users who already completed it or who are logged out. const [phase, setPhase] = useState(() => { if (!user) return "idle"; - if (isSyncDone(user.pubkey)) return "complete"; + if (isSyncDone(config.appId, user.pubkey)) return "complete"; return "idle"; }); const syncAttempted = useRef(false); @@ -60,11 +61,11 @@ export function useInitialSync() { const markSyncComplete = useCallback(() => { if (!user) return; try { - localStorage.setItem(`ditto:sync-done:${user.pubkey}`, "1"); + localStorage.setItem(getStorageKey(config.appId, `sync-done:${user.pubkey}`), "1"); } catch { // localStorage may not be available } - }, [user]); + }, [user, config.appId]); // Reset when user changes useEffect(() => { @@ -75,7 +76,7 @@ export function useInitialSync() { } // Skip sync if already completed for this user - if (isSyncDone(user.pubkey)) { + if (isSyncDone(config.appId, user.pubkey)) { setPhase("complete"); return; } @@ -322,7 +323,7 @@ export function useInitialSync() { } queryClient.setQueryData(["muteItems", muteEvent.id], items); - setCachedMuteItems(user.pubkey, items); + setCachedMuteItems(config.appId, user.pubkey, items); foundSettings = true; } diff --git a/src/hooks/useMuteList.ts b/src/hooks/useMuteList.ts index 6b10fc17..77d0ba7e 100644 --- a/src/hooks/useMuteList.ts +++ b/src/hooks/useMuteList.ts @@ -5,20 +5,24 @@ import { nip19 } from 'nostr-tools'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; +import { useAppContext } from './useAppContext'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { getStorageKey } from '@/lib/storageKey'; export interface MuteListItem { type: 'pubkey' | 'hashtag' | 'word' | 'thread'; value: string; } -/** localStorage key for cached mute list items. */ -const MUTE_CACHE_KEY = 'ditto:muteListCache'; +/** Build the localStorage key for cached mute list items. */ +export function getMuteCacheKey(appId: string): string { + return getStorageKey(appId, 'muteListCache'); +} /** Read cached mute items from localStorage for a given user. */ -function getCachedMuteItems(pubkey: string): MuteListItem[] | undefined { +function getCachedMuteItems(cacheKey: string, pubkey: string): MuteListItem[] | undefined { try { - const raw = localStorage.getItem(MUTE_CACHE_KEY); + const raw = localStorage.getItem(cacheKey); if (!raw) return undefined; const cached = JSON.parse(raw); if (cached.pubkey !== pubkey || !Array.isArray(cached.items)) return undefined; @@ -29,9 +33,9 @@ function getCachedMuteItems(pubkey: string): MuteListItem[] | undefined { } /** Persist decrypted mute items to localStorage. */ -export function setCachedMuteItems(pubkey: string, items: MuteListItem[]): void { +export function setCachedMuteItems(appId: string, pubkey: string, items: MuteListItem[]): void { try { - localStorage.setItem(MUTE_CACHE_KEY, JSON.stringify({ pubkey, items })); + localStorage.setItem(getMuteCacheKey(appId), JSON.stringify({ pubkey, items })); } catch { // Storage full or unavailable — non-critical } @@ -144,11 +148,13 @@ async function getAllMuteItems( export function useMuteList() { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const queryClient = useQueryClient(); const { mutateAsync: publishEvent } = useNostrPublish(); + const cacheKey = getMuteCacheKey(config.appId); // Placeholder from localStorage so mutes apply immediately on page load - const cachedItems = user ? getCachedMuteItems(user.pubkey) : undefined; + const cachedItems = user ? getCachedMuteItems(cacheKey, user.pubkey) : undefined; // Query the current mute list const query = useQuery({ @@ -181,7 +187,7 @@ export function useMuteList() { const items = await getAllMuteItems(event, user.signer, user.pubkey); // Persist to localStorage for next page load - setCachedMuteItems(user.pubkey, items); + setCachedMuteItems(config.appId, user.pubkey, items); return items; }, @@ -216,7 +222,7 @@ export function useMuteList() { : [...currentItems, { ...item, value: normalizedValue }]; // Update localStorage immediately so it survives page refresh - setCachedMuteItems(user.pubkey, newItems); + setCachedMuteItems(config.appId, user.pubkey, newItems); await updateMuteList(newItems, prev); }, @@ -240,7 +246,7 @@ export function useMuteList() { ); // Update localStorage immediately so it survives page refresh - setCachedMuteItems(user.pubkey, newItems); + setCachedMuteItems(config.appId, user.pubkey, newItems); await updateMuteList(newItems, prev); }, diff --git a/src/lib/helpContent.ts b/src/lib/helpContent.ts index c54a5461..800db1a7 100644 --- a/src/lib/helpContent.ts +++ b/src/lib/helpContent.ts @@ -2,9 +2,12 @@ * Structured FAQ content for the Help section. * * This module is the single source of truth for all Help/FAQ data. - * Any page can import `FAQ_CATEGORIES` or use `getFAQItems()` to render - * a full FAQ or a filtered subset (e.g. only "payments" questions on a - * wallet settings page). + * Any page can call `getFAQCategories(appName)` or `getFAQItems(appName)` to + * render a full FAQ or a filtered subset (e.g. only "payments" questions on + * a wallet settings page). + * + * Author-visible strings containing the app name are stored with the + * `{appName}` placeholder and substituted at read-time by the helpers. */ // ── Types ───────────────────────────────────────────────────────────────────── @@ -31,7 +34,12 @@ export interface FAQCategory { // ── Data ────────────────────────────────────────────────────────────────────── -export const FAQ_CATEGORIES: FAQCategory[] = [ +/** + * Raw FAQ template content. Strings may contain the literal `{appName}` + * placeholder, which is substituted at read-time by `getFAQCategories()` + * and friends. + */ +const FAQ_TEMPLATE: FAQCategory[] = [ // ── Getting Started ───────────────────────────────────────────────────── { id: 'getting-started', @@ -39,10 +47,10 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ items: [ { id: 'what-is-ditto', - question: 'What is Ditto?', + question: 'What is {appName}?', answer: [ - 'Ditto is a social media platform built on Nostr \u2014 a new kind of open, decentralized network. Think of Ditto as the app you\'re using right now to connect with people, post, and discover content.', - 'Because Ditto is built on Nostr, your account isn\'t locked to this site. You own your identity and can take it to any other Nostr app. Learn more at [soapbox.pub/ditto](https://soapbox.pub/ditto).', + '{appName} is a social media platform built on Nostr \u2014 a new kind of open, decentralized network. Think of {appName} as the app you\'re using right now to connect with people, post, and discover content.', + 'Because {appName} is built on Nostr, your account isn\'t locked to this site. You own your identity and can take it to any other Nostr app. Learn more at [soapbox.pub/ditto](https://soapbox.pub/ditto).', ], }, { @@ -55,9 +63,9 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ }, { id: 'login-other-apps', - question: 'Can I log into other Nostr apps with my Ditto account?', + question: 'Can I log into other Nostr apps with my {appName} account?', answer: [ - 'Yes! Your Ditto account **is** a Nostr account. You can use the same keys to log into any Nostr app \u2014 Primal, Damus, Amethyst, Coracle, and many more. Your posts, followers, and profile carry over everywhere.', + 'Yes! Your {appName} account **is** a Nostr account. You can use the same keys to log into any Nostr app \u2014 Primal, Damus, Amethyst, Coracle, and many more. Your posts, followers, and profile carry over everywhere.', 'Explore the full range of Nostr apps at [nostrapps.com](https://nostrapps.com/).', ], }, @@ -87,9 +95,9 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ }, { id: 'cost-to-use', - question: 'Does Ditto cost anything?', + question: 'Does {appName} cost anything?', answer: [ - '**Nope!** Ditto is completely free to use. Zaps (tips) are optional and just for fun. There are no premium tiers, no paywalls, no hidden fees.', + '**Nope!** {appName} is completely free to use. Zaps (tips) are optional and just for fun. There are no premium tiers, no paywalls, no hidden fees.', ], }, { @@ -113,7 +121,7 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ question: 'Can I download this on the App Store or Google Play?', answer: [ 'This site works as a web app right from your browser \u2014 no download needed! You can also "Add to Home Screen" on your phone to get an app-like experience.', - 'On Android, you can download Ditto from [Zap Store](https://zapstore.dev/apps/pub.ditto.app), a community-driven app store for the Nostr ecosystem. iOS support is planned for the future \u2014 stay tuned!', + 'On Android, you can download {appName} from [Zap Store](https://zapstore.dev/apps/pub.ditto.app), a community-driven app store for the Nostr ecosystem. iOS support is planned for the future \u2014 stay tuned!', ], }, { @@ -128,7 +136,7 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ id: 'nostr-app-store', question: 'Is there a Nostr-specific app store?', answer: [ - 'Yes! [Zap Store](https://zapstore.dev/) is a community-driven app store built specifically for the Nostr ecosystem. You can discover and download Nostr apps, and the apps are verified by the community rather than a corporation. Ditto is listed there \u2014 [get it on Zap Store](https://zapstore.dev/apps/pub.ditto.app).', + 'Yes! [Zap Store](https://zapstore.dev/) is a community-driven app store built specifically for the Nostr ecosystem. You can discover and download Nostr apps, and the apps are verified by the community rather than a corporation. {appName} is listed there \u2014 [get it on Zap Store](https://zapstore.dev/apps/pub.ditto.app).', 'You can also browse a directory of Nostr apps at [nostrapps.com](https://nostrapps.com/).', ], }, @@ -298,14 +306,14 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ question: 'What does "open source" mean, and why does it matter?', answer: [ 'Open source means the code that powers this app is publicly available for anyone to read, verify, and improve. There are no hidden algorithms, no secret data collection, and no backdoors.', - 'Anyone can check exactly what the software does. It\'s the digital equivalent of a restaurant with a glass kitchen \u2014 nothing to hide. You can browse the [Ditto source code](https://gitlab.com/soapbox-pub/ditto) yourself, or if you want to try editing Ditto, you can jump right in with [Shakespeare](https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git).', + 'Anyone can check exactly what the software does. It\'s the digital equivalent of a restaurant with a glass kitchen \u2014 nothing to hide. You can browse the [{appName} source code](https://gitlab.com/soapbox-pub/ditto) yourself, or if you want to try editing {appName}, you can jump right in with [Shakespeare](https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git).', ], }, { id: 'self-host', - question: 'Can I self-host Ditto?', + question: 'Can I self-host {appName}?', answer: [ - 'Yes! Because Ditto is open source, anyone can run their own instance. You get full control over your server, your data, and your community.', + 'Yes! Because {appName} is open source, anyone can run their own instance. You get full control over your server, your data, and your community.', 'If you\'re interested, check out the [self-hosting guide](https://about.ditto.pub/self-hosting) to get started.', ], }, @@ -314,7 +322,7 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ question: 'Who made this?', answer: [ 'This platform is built by [Soapbox](https://soapbox.pub), a team of developers who believe social media should be owned by its users, not corporations.', - 'Soapbox builds open-source tools for the Nostr ecosystem, including Ditto (the server that powers this site). You can learn more about the team and their mission at [soapbox.pub](https://soapbox.pub).', + 'Soapbox builds open-source tools for the Nostr ecosystem, including {appName} (the server that powers this site). You can learn more about the team and their mission at [soapbox.pub](https://soapbox.pub).', ], }, ], @@ -323,19 +331,51 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ // ── Helpers ─────────────────────────────────────────────────────────────────── +/** Replace all occurrences of `{appName}` in a string with the resolved value. */ +function substitute(str: string, appName: string): string { + return str.replaceAll('{appName}', appName); +} + +/** Substitute placeholders in a single FAQ item. */ +function substituteItem(item: FAQItem, appName: string): FAQItem { + return { + ...item, + question: substitute(item.question, appName), + answer: item.answer.map((p) => substitute(p, appName)), + }; +} + +/** Substitute placeholders in a single category (questions + answers). */ +function substituteCategory(cat: FAQCategory, appName: string): FAQCategory { + return { + ...cat, + label: substitute(cat.label, appName), + description: cat.description ? substitute(cat.description, appName) : undefined, + items: cat.items.map((i) => substituteItem(i, appName)), + }; +} + +/** + * Return the full list of FAQ categories with `{appName}` placeholders + * resolved to the given `appName`. + */ +export function getFAQCategories(appName: string): FAQCategory[] { + return FAQ_TEMPLATE.map((c) => substituteCategory(c, appName)); +} + /** Flat list of every FAQ item, optionally filtered by category ID. */ -export function getFAQItems(categoryId?: string): FAQItem[] { +export function getFAQItems(appName: string, categoryId?: string): FAQItem[] { const cats = categoryId - ? FAQ_CATEGORIES.filter((c) => c.id === categoryId) - : FAQ_CATEGORIES; - return cats.flatMap((c) => c.items); + ? FAQ_TEMPLATE.filter((c) => c.id === categoryId) + : FAQ_TEMPLATE; + return cats.flatMap((c) => c.items).map((i) => substituteItem(i, appName)); } /** Look up a single FAQ item by its ID across all categories. */ -export function getFAQItem(itemId: string): FAQItem | undefined { - for (const cat of FAQ_CATEGORIES) { +export function getFAQItem(appName: string, itemId: string): FAQItem | undefined { + for (const cat of FAQ_TEMPLATE) { const found = cat.items.find((i) => i.id === itemId); - if (found) return found; + if (found) return substituteItem(found, appName); } return undefined; } diff --git a/src/lib/iframeSubdomain.ts b/src/lib/iframeSubdomain.ts index 18090df2..9d56a492 100644 --- a/src/lib/iframeSubdomain.ts +++ b/src/lib/iframeSubdomain.ts @@ -3,20 +3,20 @@ import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; import { hexToBase36 } from '@/lib/nsiteSubdomain'; - -const SEED_STORAGE_KEY = 'ditto:seed'; +import { getStorageKey } from '@/lib/storageKey'; /** * Get or create a device-local random seed persisted in localStorage. * This is a general-purpose secret used to derive private identifiers * (e.g. sandbox frame subdomains) that must not be predictable by third parties. */ -function getSeed(): string { - const stored = localStorage.getItem(SEED_STORAGE_KEY); +function getSeed(appId: string): string { + const key = getStorageKey(appId, 'seed'); + const stored = localStorage.getItem(key); if (stored) return stored; const seed = crypto.randomUUID(); - localStorage.setItem(SEED_STORAGE_KEY, seed); + localStorage.setItem(key, seed); return seed; } @@ -35,9 +35,11 @@ function getSeed(): string { * * The result is a 50-character base36 string (256 bits of entropy) that * fits within the 63-character subdomain label limit. + * + * @param appId The app's configured `appId` — used to namespace the device seed in localStorage. */ -export function deriveIframeSubdomain(prefix: string, identifier: string): string { - const seed = getSeed(); +export function deriveIframeSubdomain(appId: string, prefix: string, identifier: string): string { + const seed = getSeed(appId); const enc = new TextEncoder(); const mac = hmac(sha256, enc.encode(seed), enc.encode(`${prefix}|${identifier}`)); return hexToBase36(bytesToHex(mac)); diff --git a/src/lib/storageKey.ts b/src/lib/storageKey.ts new file mode 100644 index 00000000..e6ae481f --- /dev/null +++ b/src/lib/storageKey.ts @@ -0,0 +1,16 @@ +/** + * Build a namespaced localStorage key using the app's configured `appId`. + * + * This keeps per-fork storage isolated and prevents two forks running on the + * same origin (e.g. during local development) from clobbering each other's + * preferences. + * + * @example + * // In a React component / hook: + * const { config } = useAppContext(); + * const key = getStorageKey(config.appId, 'showGlobalFeed'); + * // → "ditto:showGlobalFeed" (on the default build) + */ +export function getStorageKey(appId: string, suffix: string): string { + return `${appId}:${suffix}`; +} diff --git a/src/pages/AIChatPage.tsx b/src/pages/AIChatPage.tsx index d05a87f6..f161a918 100644 --- a/src/pages/AIChatPage.tsx +++ b/src/pages/AIChatPage.tsx @@ -171,9 +171,11 @@ function useToolExecutor() { // ─── System Prompt ─── -const SYSTEM_PROMPT: ChatMessage = { - role: 'system', - content: `You are Dork, extraordinaire. You are an AI assistant integrated into Ditto, a Nostr social client. You can help users with questions, conversations, and tasks. +/** Build the system prompt with the configured app name woven in. */ +function buildSystemPrompt(appName: string): ChatMessage { + return { + role: 'system', + content: `You are Dork, extraordinaire. You are an AI assistant integrated into ${appName}, a Nostr social client. You can help users with questions, conversations, and tasks. You have a set_theme tool that applies a full custom theme. It supports: @@ -189,7 +191,8 @@ You have a set_theme tool that applies a full custom theme. It supports: When the user asks to change the theme, be creative — combine colors, fonts, and backgrounds to create a cohesive aesthetic. Always set colors. Add a font when it enhances the mood. Add a background image only when you have a suitable URL or the user requests one. Be concise and friendly. When you use a tool, briefly describe the theme you created.`, -}; + }; +} // ─── Page Component ─── @@ -262,7 +265,7 @@ export function AIChatPage() { // Build the chat messages array for the API (includes system prompt + conversation history) const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => { - const apiMessages: ChatMessage[] = [SYSTEM_PROMPT]; + const apiMessages: ChatMessage[] = [buildSystemPrompt(config.appName)]; for (const msg of displayMsgs) { if (msg.role === 'tool_result') continue; // Tool results are internal @@ -270,7 +273,7 @@ export function AIChatPage() { } return apiMessages; - }, []); + }, [config.appName]); // Handle sending a message const handleSend = useCallback(async () => { diff --git a/src/pages/CSAEPolicyPage.tsx b/src/pages/CSAEPolicyPage.tsx index 889472ba..bbb6a536 100644 --- a/src/pages/CSAEPolicyPage.tsx +++ b/src/pages/CSAEPolicyPage.tsx @@ -45,8 +45,8 @@ export function CSAEPolicyPage() {

  • - Our infrastructure: We operate the Ditto relay and{' '} - Ditto Blossom server, which serve as the default relay and file host for + Our infrastructure: We operate the {config.appName} relay and{' '} + {config.appName} Blossom server, which serve as the default relay and file host for {' '}{config.appName}. We have full moderation control over content stored on these services.
  • @@ -61,8 +61,8 @@ export function CSAEPolicyPage() {

- We take full responsibility for the experience within our app. On our own infrastructure (Ditto relay - and Ditto Blossom server), we can directly remove content and ban offending accounts. For content + We take full responsibility for the experience within our app. On our own infrastructure ({config.appName} relay + and {config.appName} Blossom server), we can directly remove content and ban offending accounts. For content originating from third-party services, we actively block it from being displayed within {' '}{config.appName}.

@@ -117,11 +117,11 @@ export function CSAEPolicyPage() { suspected CSAE content for immediate review.
  • - Ditto relay moderation: On our own Ditto relay, we actively moderate content and + {config.appName} relay moderation: On our own {config.appName} relay, we actively moderate content and will immediately remove any CSAE material and permanently ban associated accounts.
  • - Ditto Blossom server moderation: On our own Ditto Blossom file server, we will + {config.appName} Blossom server moderation: On our own {config.appName} Blossom file server, we will immediately delete any CSAE media and ban the uploading account.
  • @@ -148,7 +148,7 @@ export function CSAEPolicyPage() { the app through content filters and blocklists.
  • - Removal from Ditto infrastructure: CSAE content on the Ditto relay and Ditto + Removal from {config.appName} infrastructure: CSAE content on the {config.appName} relay and {config.appName} Blossom server will be immediately deleted, and the associated accounts permanently banned.
  • @@ -223,7 +223,7 @@ export function CSAEPolicyPage() {

    • - Provide any information available to us -- including data from the Ditto relay and Ditto Blossom + Provide any information available to us -- including data from the {config.appName} relay and {config.appName} Blossom server -- that may assist in investigations, in accordance with applicable law.
    • @@ -248,7 +248,7 @@ export function CSAEPolicyPage() {
      • Full control over our own infrastructure: We can and do remove content from the - Ditto relay and Ditto Blossom server. CSAE material found on our infrastructure is deleted + {config.appName} relay and {config.appName} Blossom server. CSAE material found on our infrastructure is deleted immediately and accounts are permanently banned.
      • diff --git a/src/pages/FollowPage.tsx b/src/pages/FollowPage.tsx index 5f53c7db..0d250c1b 100644 --- a/src/pages/FollowPage.tsx +++ b/src/pages/FollowPage.tsx @@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { NoteCard } from '@/components/NoteCard'; import { getAvatarShape, isEmoji, emojiAvatarBorderStyle } from '@/lib/avatarShape'; import { cn } from '@/lib/utils'; +import { useAppContext } from '@/hooks/useAppContext'; import { useAuthor } from '@/hooks/useAuthor'; import { useAuthors } from '@/hooks/useAuthors'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -175,6 +176,7 @@ function ProfileFeed({ pubkey }: { pubkey: string }) { function FollowView({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { data: followData } = useFollowList(); const { isPending, follow } = useFollowActions(); const { toast } = useToast(); @@ -282,7 +284,7 @@ function FollowView({ pubkey }: { pubkey: string }) { className="w-full rounded-full py-3 text-base font-semibold" size="lg" > - Follow {displayName} on Ditto + Follow {displayName} on {config.appName} ) : isOwnProfile ? (
        @@ -357,6 +359,7 @@ function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[] const { data: event, isLoading: eventLoading } = useAddrEvent(addr, relays); const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { config } = useAppContext(); const { data: followList } = useFollowList(); const { mutateAsync: publishEvent } = useNostrPublish(); const { toast } = useToast(); @@ -535,7 +538,7 @@ function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[] size="lg" > - Follow {pubkeys.length} people on Ditto + Follow {pubkeys.length} people on {config.appName} ) : isFollowingAll ? ( + ); + } + + return ( + + ); +} diff --git a/src/components/BadgeAwardCard.tsx b/src/components/BadgeAwardCard.tsx new file mode 100644 index 00000000..a969acba --- /dev/null +++ b/src/components/BadgeAwardCard.tsx @@ -0,0 +1,172 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import { Award } from 'lucide-react'; +import { nip19 } from 'nostr-tools'; +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; + +import { AcceptBadgeButton } from '@/components/AcceptBadgeButton'; +import { BadgeContent } from '@/components/BadgeContent'; +import { EmojifiedText } from '@/components/CustomEmoji'; +import { ProfileHoverCard } from '@/components/ProfileHoverCard'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { + BADGE_DEFINITION_KIND, + getBadgeRecipients, + isAwardedTo, + parseBadgeATag, + unslugify, +} from '@/lib/badgeUtils'; +import { genUserName } from '@/lib/genUserName'; + +interface BadgeAwardCardProps { + /** The kind 8 badge award event. */ + event: NostrEvent; +} + +/** + * Feed card for NIP-58 badge award events (kind 8). Shows a linked recipient + * row, the full badge showcase (via `BadgeContent`), and an Accept button when + * the logged-in user is a recipient. The issuer's avatar and name are rendered + * by the surrounding `NoteCard`. + */ +export function BadgeAwardCard({ event }: BadgeAwardCardProps) { + const { user } = useCurrentUser(); + + const recipients = useMemo(() => getBadgeRecipients(event), [event]); + const parsed = useMemo(() => parseBadgeATag(event), [event]); + + // NIP-58: only the badge owner can validly award their own badge. Ignore + // definitions whose a-tag pubkey doesn't match the award's issuer. + const validParsed = parsed && parsed.pubkey === event.pubkey ? parsed : undefined; + const badgeRef = useMemo(() => (validParsed ? [validParsed] : []), [validParsed]); + const { badgeMap } = useBadgeDefinitions(badgeRef); + + const aTag = validParsed + ? `${BADGE_DEFINITION_KIND}:${validParsed.pubkey}:${validParsed.identifier}` + : undefined; + const definition = aTag ? badgeMap.get(aTag) : undefined; + const definitionEvent = definition?.event; + + const badgeNaddr = useMemo( + () => + validParsed + ? nip19.naddrEncode({ + kind: BADGE_DEFINITION_KIND, + pubkey: validParsed.pubkey, + identifier: validParsed.identifier, + }) + : undefined, + [validParsed], + ); + + const isRecipient = user ? isAwardedTo(event, user.pubkey) : false; + const firstRecipient = recipients[0]; + const extraRecipientCount = Math.max(recipients.length - 1, 0); + + return ( +
        + {/* Recipient(s) row — "to @Alice" / "to @Alice and 2 others" */} + {firstRecipient && ( +
        + to + + {extraRecipientCount > 0 && ( + + and {extraRecipientCount}{' '} + {extraRecipientCount === 1 ? 'other' : 'others'} + + )} +
        + )} + + {/* Badge showcase — click-through to the badge detail page */} + {definitionEvent ? ( + badgeNaddr ? ( + e.stopPropagation()} + > + + + ) : ( + + ) + ) : ( + + )} + + {/* Accept button — only shown when the logged-in user is a recipient */} + {isRecipient && ( +
        + +
        + )} +
        + ); +} + +/** Linked display name for a recipient pubkey, with loading skeleton and hover card. */ +function RecipientName({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + const displayName = metadata?.name ?? genUserName(pubkey); + const url = useProfileUrl(pubkey, metadata); + + if (author.isLoading) { + return ; + } + + return ( + + e.stopPropagation()} + > + {author.data?.event ? ( + {displayName} + ) : ( + displayName + )} + + + ); +} + +/** Fallback shown while the badge definition is loading or missing. */ +function BadgeShowcaseFallback({ + name, + href, +}: { + name: string | undefined; + href: string | undefined; +}) { + const body = ( +
        +
        + +
        + {name ? ( +

        {name}

        + ) : ( + + )} +
        + ); + + if (!href) return body; + + return ( + e.stopPropagation()}> + {body} + + ); +} diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index 1285f587..86c75cb1 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -98,6 +98,7 @@ const KIND_LABELS: Record = { 4: 'an encrypted message', 6: 'a repost', 7: 'a reaction', + 8: 'a badge award', 16: 'a repost', 20: 'a photo', 21: 'a video', @@ -151,6 +152,7 @@ const KIND_ICONS: Partial; } + // Kind 8 badge award events get a compact badge card + if (event.kind === BADGE_AWARD_KIND) { + return ; + } + return ; } +/** Compact inline card for kind 8 NIP-58 badge award events. */ +function EmbeddedBadgeAwardCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) { + const navigate = useNavigate(); + + const neventId = useMemo( + () => nip19.neventEncode({ id: event.id, author: event.pubkey }), + [event.id, event.pubkey], + ); + + const parsed = useMemo(() => parseBadgeATag(event), [event]); + // NIP-58: only the badge owner can validly award their own badge. + const validParsed = parsed && parsed.pubkey === event.pubkey ? parsed : undefined; + const badgeRef = useMemo(() => (validParsed ? [validParsed] : []), [validParsed]); + const { badgeMap } = useBadgeDefinitions(badgeRef); + + const aTag = validParsed + ? `${BADGE_DEFINITION_KIND}:${validParsed.pubkey}:${validParsed.identifier}` + : undefined; + const badge = aTag ? badgeMap.get(aTag) : undefined; + const badgeName = badge?.name || (validParsed ? unslugify(validParsed.identifier) : 'Badge'); + + const issuer = useAuthor(event.pubkey); + const issuerMeta = issuer.data?.metadata; + const issuerName = issuerMeta?.name || genUserName(event.pubkey); + const issuerProfileUrl = useProfileUrl(event.pubkey, issuerMeta); + + return ( +
        { + e.stopPropagation(); + navigate(`/${neventId}`); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + navigate(`/${neventId}`); + } + }} + > +
        + {/* Badge thumbnail or fallback icon */} + {badge ? ( + + ) : ( +
        + +
        + )} + + {/* Text */} +
        +
        + + e.stopPropagation()} + > + {issuer.data?.event ? ( + {issuerName} + ) : issuerName} + + + awarded a badge + + · {timeAgo(event.created_at)} + +
        +

        + {badgeName} +

        +
        +
        +
        + ); +} + /** Compact inline card for kind 9735 zap receipts. */ function EmbeddedZapCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) { const navigate = useNavigate(); diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index fe666c2f..b735dbc8 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -33,6 +33,7 @@ import { PodcastEpisodeContent, PodcastTrailerContent, } from "@/components/AudioKindContent"; +import { BadgeAwardCard } from "@/components/BadgeAwardCard"; import { BadgeContent } from "@/components/BadgeContent"; import { CalendarEventContent } from "@/components/CalendarEventContent"; import { @@ -403,7 +404,8 @@ export const NoteCard = memo(function NoteCard({ const isEmojiPack = event.kind === 30030; const isBadgeDefinition = event.kind === 30009; const isProfileBadges = event.kind === 10008 || event.kind === 30008; - const isBadge = isBadgeDefinition || isProfileBadges; + const isBadgeAward = event.kind === 8; + const isBadge = isBadgeDefinition || isProfileBadges || isBadgeAward; const isReaction = event.kind === 7; const isPollVote = event.kind === 1018; const isRepost = event.kind === 6 || event.kind === 16; @@ -603,6 +605,8 @@ export const NoteCard = memo(function NoteCard({ ) : isProfileBadges ? ( + ) : isBadgeAward ? ( + ) : isTheme ? ( ) : isVoiceMessage ? ( @@ -1704,6 +1708,12 @@ const KIND_HEADER_MAP: Record = { noun: "emoji pack", nounRoute: "/emojis", }, + 8: { + icon: Award, + action: "awarded a", + noun: "badge", + nounRoute: "/badges", + }, 30009: { icon: Award, action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }), diff --git a/src/lib/badgeUtils.ts b/src/lib/badgeUtils.ts index c0b317bf..722b3a23 100644 --- a/src/lib/badgeUtils.ts +++ b/src/lib/badgeUtils.ts @@ -62,3 +62,35 @@ export async function fetchFreshProfileBadges( export function isAwardedTo(awardEvent: NostrEvent, pubkey: string): boolean { return awardEvent.tags.some(([n, v]) => n === 'p' && v === pubkey); } + +/** + * Extract pubkey and identifier from a kind 8 badge award event's `a` tag. + * Returns undefined if the tag is missing, malformed, or the pubkey is not a + * valid 64-char hex string (to avoid crashes in `nip19.naddrEncode`). + */ +export function parseBadgeATag( + event: NostrEvent, +): { pubkey: string; identifier: string } | undefined { + const aVal = event.tags.find( + ([n, v]) => n === 'a' && v?.startsWith(`${BADGE_DEFINITION_KIND}:`), + )?.[1]; + if (!aVal) return undefined; + const parts = aVal.split(':'); + if (parts.length < 3 || !parts[1] || !parts[2]) return undefined; + if (!/^[0-9a-f]{64}$/.test(parts[1])) return undefined; + return { pubkey: parts[1], identifier: parts.slice(2).join(':') }; +} + +/** Turn a d-tag slug like "first-post" into "First Post". */ +export function unslugify(slug: string): string { + return slug + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Extract all recipient pubkeys (`p` tags) from a kind 8 badge award event. */ +export function getBadgeRecipients(event: NostrEvent): string[] { + return event.tags + .filter(([n, v]) => n === 'p' && typeof v === 'string' && v.length > 0) + .map(([, v]) => v); +} diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 3ae01e3a..1abe7bf7 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -1,6 +1,6 @@ import type { FeedSettings } from '@/contexts/AppContext'; import type { ComponentType } from 'react'; -import { Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert } from 'lucide-react'; +import { Award, Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; @@ -539,6 +539,7 @@ export function getPageKinds(def: ExtraKindDef, feedSettings: FeedSettings): num const KIND_SPECIFIC_LABELS: Record = { 6: 'repost', 7: 'reaction', + 8: 'badge award', 16: 'repost', 1617: 'patch', 1618: 'patch comment', @@ -557,6 +558,7 @@ const KIND_SPECIFIC_LABELS: Record = { */ const KIND_SPECIFIC_ICONS: Partial>> = { 6: RepostIcon, + 8: Award, 16: RepostIcon, 1617: GitPullRequestArrow, 1618: MessageSquareMore, diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 6127571e..a27c2016 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import { useInView } from 'react-intersection-observer'; import { useSeoMeta } from '@unhead/react'; import { useQueryClient } from '@tanstack/react-query'; -import { Zap, AtSign, MessageSquare, MessageCircle, Loader2, Award, Check, Mail } from 'lucide-react'; +import { Zap, AtSign, MessageSquare, MessageCircle, Loader2, Award, Mail } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { Link, useNavigate } from 'react-router-dom'; import { PullToRefresh } from '@/components/PullToRefresh'; @@ -29,10 +29,9 @@ import { formatNumber } from '@/lib/formatNumber'; import { cn } from '@/lib/utils'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { ReactionEmoji, EmojifiedText } from '@/components/CustomEmoji'; -import { useAcceptBadge } from '@/hooks/useAcceptBadge'; -import { useProfileBadges } from '@/hooks/useProfileBadges'; import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions'; -import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils'; +import { AcceptBadgeButton } from '@/components/AcceptBadgeButton'; +import { BADGE_DEFINITION_KIND, parseBadgeATag, unslugify } from '@/lib/badgeUtils'; import { LETTER_KIND, type Letter } from '@/lib/letterTypes'; import { EnvelopeCard } from '@/components/letter/EnvelopeCard'; import { LetterDetailSheet } from '@/components/letter/LetterDetailSheet'; @@ -57,6 +56,7 @@ const NOTIFICATION_KIND_NOUNS: Record = { 4: 'encrypted message', 6: 'repost', 7: 'reaction', + 8: 'badge award', 16: 'repost', 20: 'photo', 21: 'video', @@ -786,24 +786,6 @@ function LetterNotification({ item, isNew }: { item: NotificationItem; isNew: bo // Badge Award helpers // ────────────────────────────────────── -/** Extract pubkey and identifier from a kind 8 award event's `a` tag. */ -function parseBadgeATag(event: NostrEvent): { pubkey: string; identifier: string } | undefined { - const aVal = event.tags.find(([n, v]) => n === 'a' && v?.startsWith(`${BADGE_DEFINITION_KIND}:`))?.[1]; - if (!aVal) return undefined; - const parts = aVal.split(':'); - if (parts.length < 3 || !parts[1] || !parts[2]) return undefined; - // Validate pubkey is a 64-char hex string to avoid crashes in nip19.naddrEncode - if (!/^[0-9a-f]{64}$/.test(parts[1])) return undefined; - return { pubkey: parts[1], identifier: parts.slice(2).join(':') }; -} - -/** Turn a d-tag slug like "first-post" into "First Post". */ -function unslugify(slug: string): string { - return slug - .replace(/[-_]/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()); -} - /** Hook: resolve the display name, badge data, and definition event for a single badge award event. */ function useBadgeAward(awardEvent: NostrEvent): { name: string | undefined; badge: BadgeData | undefined; definitionEvent: NostrEvent | undefined } { const parsed = useMemo(() => parseBadgeATag(awardEvent), [awardEvent]); @@ -820,73 +802,6 @@ function useBadgeAward(awardEvent: NostrEvent): { name: string | undefined; badg }; } -// ────────────────────────────────────── -// Accept Badge Button (shared by single and grouped badge notifications) -// ────────────────────────────────────── -function AcceptBadgeButton({ awardEvent, prominent }: { awardEvent: NostrEvent; prominent?: boolean }) { - const { user } = useCurrentUser(); - const { refs } = useProfileBadges(user?.pubkey); - const { mutate: acceptBadge, isPending, isSuccess } = useAcceptBadge(); - - const aTag = awardEvent.tags.find(([n, v]) => n === 'a' && v?.startsWith('30009:'))?.[1]; - - // Check if already accepted - const alreadyAccepted = refs.some((r) => r.aTag === aTag) || isSuccess; - - if (!aTag || !user) return null; - - if (alreadyAccepted) { - return ( - - - Accepted - - ); - } - - if (prominent) { - return ( - - ); - } - - return ( - - ); -} - // ────────────────────────────────────── // Badge Award Notification (single actor) // ────────────────────────────────────── diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 787f854d..b31f7cee 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -23,6 +23,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro import { Link, useNavigate } from "react-router-dom"; /** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the detail page bundle. */ const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent }))); +import { BadgeAwardCard } from "@/components/BadgeAwardCard"; import { BadgeDetailContent } from "@/components/BadgeDetailContent"; import { CalendarEventDetailPage } from "@/components/CalendarEventDetailPage"; @@ -113,6 +114,9 @@ const BADGE_PROFILE_KIND_NEW = 10008; /** NIP-58 Profile Badges (legacy addressable kind). */ const BADGE_PROFILE_KIND_LEGACY = 30008; +/** NIP-58 Badge Award. */ +const BADGE_AWARD_KIND = 8; + /** Kind 31985 = Bookstr book reviews. */ const BOOK_REVIEW_KIND = 31985; @@ -133,6 +137,7 @@ function shellTitleForKind(kind?: number): string { if (kind === 30817) return "Custom NIP"; if (kind === BADGE_DEFINITION_KIND) return "Badge Details"; if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection"; + if (kind === BADGE_AWARD_KIND) return "Badge Award"; if (kind === BOOK_REVIEW_KIND) return "Book Review"; if (kind === 32267) return "Zapstore App"; if (kind === 30063) return "Zapstore Release"; @@ -1016,6 +1021,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isZap = event.kind === 9735; const isProfile = event.kind === 0; const isBlobbiState = event.kind === 31124; + const isBadgeAward = event.kind === BADGE_AWARD_KIND; const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite; const isTextNote = !isVine && @@ -1046,7 +1052,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { !isVanish && !isZap && !isProfile && - !isBlobbiState; + !isBlobbiState && + !isBadgeAward; const { data: stats } = useEventStats(event.id, event); const { data: interactions } = useEventInteractions(event.id); @@ -2134,6 +2141,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { }> + ) : isBadgeAward ? ( + ) : isVine || isPoll || isGeocache || From 8e6bd29be0417b55f264ef6f8c6f7b5f4c4801e4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 11:39:54 -0500 Subject: [PATCH 030/330] Include kind 8 badge awards in home, profile, and Badges feeds Declares kind 8 as a third sub-kind under the existing Badges ExtraKindDef with its own 'showBadgeAwards' / 'feedIncludeBadgeAwards' toggles. The home feed and profile feed both derive their kinds list from getEnabledFeedKinds, so both pick up badge awards automatically. The Badges page's follows feed is a hardcoded list, so kind 8 is added there explicitly. Defaults match existing badge settings: enabled in hardcodedConfig (new users see them), conservative in InitialSyncGate and TestApp. The ContentSettings UI auto-generates a new Badge Awards toggle row. Removes the now-redundant KIND_SPECIFIC_LABELS/ICONS entries for kind 8 since the sub-kind carries that metadata. --- src/App.tsx | 2 ++ src/components/InitialSyncGate.tsx | 2 ++ src/contexts/AppContext.ts | 4 ++++ src/hooks/useBadgeFeed.ts | 4 ++-- src/lib/extraKinds.ts | 11 ++++++++--- src/lib/schemas.ts | 2 ++ src/test/TestApp.tsx | 2 ++ 7 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7e48f1ae..c697cd89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -114,8 +114,10 @@ const hardcodedConfig: AppConfig = { showBadges: true, showBadgeDefinitions: true, showProfileBadges: true, + showBadgeAwards: true, feedIncludeBadgeDefinitions: true, feedIncludeProfileBadges: true, + feedIncludeBadgeAwards: true, feedIncludeVanish: true, feedIncludeBlobbi: true, followsFeedShowReplies: true, diff --git a/src/components/InitialSyncGate.tsx b/src/components/InitialSyncGate.tsx index 553e218c..3a1a8e09 100644 --- a/src/components/InitialSyncGate.tsx +++ b/src/components/InitialSyncGate.tsx @@ -402,8 +402,10 @@ function SetupQuestionnaire({ showBadges: false, showBadgeDefinitions: true, showProfileBadges: true, + showBadgeAwards: true, feedIncludeBadgeDefinitions: false, feedIncludeProfileBadges: false, + feedIncludeBadgeAwards: false, feedIncludeVanish: true, feedIncludeBlobbi: true, followsFeedShowReplies: true, diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 48243b6b..78a6f656 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -142,10 +142,14 @@ export interface FeedSettings { showBadgeDefinitions: boolean; /** Show profile badges (kind 10008/30008) on the Badges page */ showProfileBadges: boolean; + /** Show badge awards (kind 8) on the Badges page */ + showBadgeAwards: boolean; /** Include badge definitions (kind 30009) in the follows/global feed */ feedIncludeBadgeDefinitions: boolean; /** Include profile badges (kind 10008/30008) in the follows/global feed */ feedIncludeProfileBadges: boolean; + /** Include badge awards (kind 8) in the follows/global feed */ + feedIncludeBadgeAwards: boolean; /** Include Request to Vanish events (kind 62) in the follows/global feed */ feedIncludeVanish: boolean; /** Include Blobbi pet updates (kind 31124) in the follows/global feed */ diff --git a/src/hooks/useBadgeFeed.ts b/src/hooks/useBadgeFeed.ts index 8da1aa57..02d2190a 100644 --- a/src/hooks/useBadgeFeed.ts +++ b/src/hooks/useBadgeFeed.ts @@ -4,7 +4,7 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from './useCurrentUser'; import { useFollowList } from './useFollowActions'; -import { BADGE_DEFINITION_KIND, BADGE_PROFILE_KIND, BADGE_PROFILE_KIND_LEGACY } from '@/lib/badgeUtils'; +import { BADGE_AWARD_KIND, BADGE_DEFINITION_KIND, BADGE_PROFILE_KIND, BADGE_PROFILE_KIND_LEGACY } from '@/lib/badgeUtils'; import { TEAM_SOAPBOX_PACK } from '@/lib/helpContent'; const PAGE_SIZE = 20; @@ -66,7 +66,7 @@ export function useBadgeFeed(tab: 'follows' = 'follows') { // Query all badge kinds in a single request (including legacy 30008) const events = await nostr.query( - [{ kinds: [BADGE_DEFINITION_KIND, BADGE_PROFILE_KIND, BADGE_PROFILE_KIND_LEGACY], ...shared }], + [{ kinds: [BADGE_DEFINITION_KIND, BADGE_AWARD_KIND, BADGE_PROFILE_KIND, BADGE_PROFILE_KIND_LEGACY], ...shared }], { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, ); diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 1abe7bf7..8ff75ddb 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -1,6 +1,6 @@ import type { FeedSettings } from '@/contexts/AppContext'; import type { ComponentType } from 'react'; -import { Award, Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert } from 'lucide-react'; +import { Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; @@ -428,6 +428,13 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ description: 'Accepted profile badges (kind 10008)', extraFeedKinds: [30008], // legacy kind for backwards compatibility }, + { + kind: 8, + showKey: 'showBadgeAwards', + feedKey: 'feedIncludeBadgeAwards', + label: 'Badge Awards', + description: 'Badge award events (kind 8)', + }, ], }, { @@ -539,7 +546,6 @@ export function getPageKinds(def: ExtraKindDef, feedSettings: FeedSettings): num const KIND_SPECIFIC_LABELS: Record = { 6: 'repost', 7: 'reaction', - 8: 'badge award', 16: 'repost', 1617: 'patch', 1618: 'patch comment', @@ -558,7 +564,6 @@ const KIND_SPECIFIC_LABELS: Record = { */ const KIND_SPECIFIC_ICONS: Partial>> = { 6: RepostIcon, - 8: Award, 16: RepostIcon, 1617: GitPullRequestArrow, 1618: MessageSquareMore, diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index e9f7c87f..c1073b6d 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -181,6 +181,8 @@ export const FeedSettingsSchema = z.looseObject({ showDevelopment: z.boolean().optional(), feedIncludeDevelopment: z.boolean().optional(), feedIncludeBlobbi: z.boolean().optional(), + showBadgeAwards: z.boolean().optional(), + feedIncludeBadgeAwards: z.boolean().optional(), }); /** Schema for a NIP-01 filter object (lenient — allows variable placeholder strings). */ diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index c69daf49..6e04a36c 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -86,8 +86,10 @@ export function TestApp({ children }: TestAppProps) { showBadges: false, showBadgeDefinitions: true, showProfileBadges: true, + showBadgeAwards: true, feedIncludeBadgeDefinitions: false, feedIncludeProfileBadges: false, + feedIncludeBadgeAwards: false, feedIncludeVanish: true, feedIncludeBlobbi: true, followsFeedShowReplies: true, From 1e694a6cf8ce4cfea6c22ef455d95c016cc0620d Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Fri, 17 Apr 2026 12:03:29 -0500 Subject: [PATCH 031/330] Remove blobbi post requirement from evolution hatch missions Add migration logic so users with stale persisted evolution missions (e.g. containing the removed create_post mission) get their mission list rebuilt to match current definitions while preserving progress. --- .../components/BlobbiMissionsModal.tsx | 7 - .../actions/components/BlobbiPostModal.tsx | 294 ------------------ .../actions/components/HatchTasksPanel.tsx | 12 +- src/blobbi/actions/components/TasksPanel.tsx | 8 - src/blobbi/actions/hooks/useEvolveTasks.ts | 12 +- src/blobbi/actions/hooks/useHatchTasks.ts | 55 +--- src/blobbi/actions/index.ts | 3 - src/blobbi/actions/lib/evolution-missions.ts | 42 ++- src/pages/BlobbiPage.tsx | 30 +- 9 files changed, 56 insertions(+), 407 deletions(-) delete mode 100644 src/blobbi/actions/components/BlobbiPostModal.tsx diff --git a/src/blobbi/actions/components/BlobbiMissionsModal.tsx b/src/blobbi/actions/components/BlobbiMissionsModal.tsx index 0cac9fd2..1244bc21 100644 --- a/src/blobbi/actions/components/BlobbiMissionsModal.tsx +++ b/src/blobbi/actions/components/BlobbiMissionsModal.tsx @@ -61,7 +61,6 @@ interface BlobbiMissionsModalProps { companion: BlobbiCompanion; hatchTasks: HatchTasksResult; evolveTasks: EvolveTasksResult; - onOpenPostModal: () => void; onHatch: () => void; isHatching: boolean; onEvolve: () => void; @@ -285,7 +284,6 @@ interface CurrentFocusSectionProps { companion: BlobbiCompanion; tasks: HatchTasksResult | EvolveTasksResult; processType: 'incubation' | 'evolution'; - onOpenPostModal: () => void; onComplete: () => void; isCompleting: boolean; onStop: () => Promise; @@ -297,7 +295,6 @@ function CurrentFocusSection({ companion, tasks, processType, - onOpenPostModal, onComplete, isCompleting, onStop, @@ -361,7 +358,6 @@ function CurrentFocusSection({ tasks={tasks.tasks} allCompleted={tasks.allCompleted} isLoading={tasks.isLoading} - onOpenPostModal={onOpenPostModal} onComplete={onComplete} isCompleting={isCompleting} completeLabel={completeLabel} @@ -426,7 +422,6 @@ export function BlobbiMissionsModal({ companion, hatchTasks, evolveTasks, - onOpenPostModal, onHatch, isHatching, onEvolve, @@ -479,7 +474,6 @@ export function BlobbiMissionsModal({ companion={companion} tasks={hatchTasks} processType="incubation" - onOpenPostModal={onOpenPostModal} onComplete={onHatch} isCompleting={isHatching} onStop={onStopIncubation} @@ -490,7 +484,6 @@ export function BlobbiMissionsModal({ companion={companion} tasks={evolveTasks} processType="evolution" - onOpenPostModal={onOpenPostModal} onComplete={onEvolve} isCompleting={isEvolving} onStop={onStopEvolution} diff --git a/src/blobbi/actions/components/BlobbiPostModal.tsx b/src/blobbi/actions/components/BlobbiPostModal.tsx deleted file mode 100644 index 1366319d..00000000 --- a/src/blobbi/actions/components/BlobbiPostModal.tsx +++ /dev/null @@ -1,294 +0,0 @@ -// src/blobbi/actions/components/BlobbiPostModal.tsx - -/** - * Modal for creating a Blobbi post (hatch or evolve). - * - * Requirements: - * - Prefilled with stage-aware text: - * - Hatch: "Hello Nostr! Posting to hatch # #blobbi #ditto #nostr" - * - Evolve: "Hello Nostr! Posting to evolve # #blobbi #ditto #nostr" - * - User can ADD text but CANNOT delete the prefix or required hashtags - * - Blobbi name is sanitized into a valid hashtag format - * - Enforced programmatically - */ - -import { useState, useCallback, useEffect, useMemo } from 'react'; -import { X, Loader2, AlertCircle } from 'lucide-react'; - -import { - Dialog, - DialogContent, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; -import { Alert, AlertDescription } from '@/components/ui/alert'; - -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { toast } from '@/hooks/useToast'; - -import { - BLOBBI_POST_REQUIRED_HASHTAGS, - buildHatchPhrase, -} from '../hooks/useHatchTasks'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -/** The process type for the post */ -export type BlobbiPostProcess = 'hatch' | 'evolve'; - -interface BlobbiPostModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - /** The Blobbi's name (will be converted to hashtag) */ - blobbiName: string; - /** The process type - 'hatch' for incubation, 'evolve' for evolution */ - process?: BlobbiPostProcess; - onSuccess?: () => void; -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/** - * Build the required prefix text based on process type. - */ -function buildPrefix(process: BlobbiPostProcess): string { - return process === 'evolve' - ? 'Posting to evolve' - : 'Posting to hatch'; -} - -// ─── Main Component ─────────────────────────────────────────────────────────── - -export function BlobbiPostModal({ - open, - onOpenChange, - blobbiName, - process = 'hatch', - onSuccess, -}: BlobbiPostModalProps) { - const { user } = useCurrentUser(); - const { mutateAsync: createEvent, isPending } = useNostrPublish(); - - // Compute the required elements based on props - const prefix = useMemo(() => buildPrefix(process), [process]); - const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]); - - // The required phrase that must appear in the post - const requiredPhrase = useMemo(() => - process === 'hatch' - ? buildHatchPhrase(blobbiName) - : `${prefix} ${capitalizedName} #blobbi`, - [process, blobbiName, prefix, capitalizedName] - ); - - // Build default content (the phrase itself is enough) - const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]); - - const [content, setContent] = useState(defaultContent); - const [validationError, setValidationError] = useState(null); - - // Reset content when modal opens or props change - useEffect(() => { - if (open) { - setContent(defaultContent); - setValidationError(null); - } - }, [open, defaultContent]); - - /** - * Validate that the content contains the required phrase. - */ - const validateContent = useCallback((text: string): string | null => { - if (!text.includes(requiredPhrase)) { - return `The post must contain: "${requiredPhrase}"`; - } - return null; - }, [requiredPhrase]); - - /** - * Handle content change with validation. - * Prevents deletion of required content. - */ - const handleContentChange = useCallback((e: React.ChangeEvent) => { - const newContent = e.target.value; - - // Allow content changes only if it preserves the required elements - const error = validateContent(newContent); - - if (error) { - setValidationError(error); - // Still update content but show error - // This allows the user to see what they're trying to do - // but the post button will be disabled - } else { - setValidationError(null); - } - - setContent(newContent); - }, [validateContent]); - - /** - * Handle post creation. - */ - const handlePost = useCallback(async () => { - if (!user?.pubkey) { - toast({ - title: 'Not logged in', - description: 'Please log in to create a post', - variant: 'destructive', - }); - return; - } - - // Final validation - const error = validateContent(content); - if (error) { - setValidationError(error); - return; - } - - try { - // Build tags for the post: extract all hashtags from content - const tags: string[][] = []; - const seen = new Set(); - - // Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags - for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) { - const lower = hashtag.toLowerCase(); - if (!seen.has(lower)) { - tags.push(['t', lower]); - seen.add(lower); - } - } - - // Extract any additional hashtags from the content - const contentHashtags = content.match(/#(\w+)/g) || []; - for (const tag of contentHashtags) { - const tagValue = tag.slice(1).toLowerCase(); - if (!seen.has(tagValue)) { - tags.push(['t', tagValue]); - seen.add(tagValue); - } - } - - await createEvent({ - kind: 1, - content, - tags, - }); - - toast({ - title: 'Post created!', - description: process === 'evolve' - ? 'Your Blobbi evolution post has been published.' - : 'Your Blobbi hatch post has been published.', - }); - - onOpenChange(false); - onSuccess?.(); - } catch (error) { - toast({ - title: 'Failed to create post', - description: error instanceof Error ? error.message : 'Unknown error', - variant: 'destructive', - }); - } - }, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]); - - const canPost = !validationError && content.trim().length > 0; - - const dialogTitle = process === 'evolve' ? 'Blobbi Evolution Post' : 'Blobbi Hatch Post'; - const alertText = process === 'evolve' - ? "This special post announces your Blobbi's evolution! The highlighted text must remain in your post." - : "This special post announces your Blobbi's hatching journey! The highlighted text must remain in your post."; - - return ( - - - {/* Header */} -
        - - {dialogTitle} - - -
        - - {/* Content */} -
        - {/* Info alert */} - - - {alertText} - - - - {/* Textarea */} -
        -