From f24db916347907d042d2fda5f9fa9553210a18f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 18:38:39 +0000 Subject: [PATCH] refactor: create reusable useNip19Decode hook and improve preview pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit makes the preview pages production-ready by: 1. Created useNip19Decode hook (src/hooks/useNip19Decode.ts): - Reusable hook for decoding NIP-19 identifiers (npub, note, nevent, naddr, nprofile) - Type-safe with discriminated union for decoded entities - Comprehensive error handling with retry functionality - Loading states and error messages - Well-documented with JSDoc comments and usage examples 2. Comprehensive test coverage (src/hooks/useNip19Decode.test.ts): - 11 tests covering all entity types (npub, note, nevent, naddr) - Tests for error handling (missing identifier, invalid format, corrupted bech32) - Tests for retry functionality and state changes - Uses jsdom environment for React hook testing - All tests passing ✓ 3. Refactored preview pages to use the hook: - PreviewProfilePage: Simplified from 80 to 81 lines with cleaner logic - PreviewEventPage: Improved type safety and error handling - PreviewAddressPage: Better separation of concerns - All pages now have consistent error handling and retry functionality - Better user experience with improved error messages 4. Dependencies added: - @testing-library/react for React hook testing - @testing-library/dom for DOM testing utilities - jsdom and happy-dom for browser environment simulation in tests Benefits: - Code deduplication: Preview pages share decoding logic - Type safety: Discriminated union prevents type errors - Testability: Hook can be tested independently - Maintainability: Single source of truth for NIP-19 decoding - User experience: Consistent error handling and retry across all preview pages - Production-ready: Comprehensive tests and error handling --- package-lock.json | 769 ++++++++++++++++++++ package.json | 4 + src/components/pages/PreviewAddressPage.tsx | 110 ++- src/components/pages/PreviewEventPage.tsx | 105 +-- src/components/pages/PreviewProfilePage.tsx | 70 +- src/hooks/useNip19Decode.test.ts | 216 ++++++ src/hooks/useNip19Decode.ts | 134 ++++ 7 files changed, 1287 insertions(+), 121 deletions(-) create mode 100644 src/hooks/useNip19Decode.test.ts create mode 100644 src/hooks/useNip19Decode.ts diff --git a/package-lock.json b/package-lock.json index 9e5660b..26cfa02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,8 @@ "devDependencies": { "@eslint/js": "^9.17.0", "@react-router/dev": "^7.1.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.1", "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.1", "@types/prismjs": "^1.26.5", @@ -80,6 +82,8 @@ "eslint-plugin-react-refresh": "^0.4.16", "fake-indexeddb": "^6.2.5", "globals": "^15.14.0", + "happy-dom": "^20.0.11", + "jsdom": "^27.4.0", "postcss": "^8.4.49", "prettier": "^3.7.4", "tailwindcss": "^3.4.17", @@ -89,6 +93,13 @@ "vitest": "^4.0.15" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.30", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", + "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -102,6 +113,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -649,6 +715,141 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", + "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1248,6 +1449,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz", + "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@exodus/crypto": "^1.0.0-rc.4" + }, + "peerDependenciesMeta": { + "@exodus/crypto": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -3956,6 +4175,61 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4145,6 +4419,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", @@ -4616,6 +4897,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4888,6 +5179,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4976,6 +5277,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5410,6 +5721,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5423,6 +5748,32 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.6.tgz", + "integrity": "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5435,6 +5786,30 @@ "integrity": "sha512-cjrsQufETwxjvwZbYbKBCJNvmQ2++G9AvT45zDi7NXL9k2PdVcs2h0jQz96J6G4TMKRCcEsoJ+QTgQD00Igtjw==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -5471,6 +5846,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -5595,6 +5977,13 @@ "dnd-core": "^16.0.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5616,6 +6005,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -6307,6 +6709,38 @@ "dev": true, "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.0.11", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", + "integrity": "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6425,6 +6859,19 @@ "node": ">=12" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -6435,6 +6882,34 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6611,6 +7086,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isbot": { "version": "5.1.32", "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.32.tgz", @@ -6701,6 +7183,56 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -6893,6 +7425,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7195,6 +7737,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-chrome": { "version": "4.17.2", "resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.17.2.tgz", @@ -8197,6 +8746,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8524,6 +9086,51 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -9084,6 +9691,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -9217,6 +9834,19 @@ "tslib": "^2.1.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9596,6 +10226,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -9809,6 +10446,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9832,6 +10489,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -10433,6 +11116,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", @@ -10577,6 +11307,45 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index 655417a..bc23b5a 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,8 @@ "devDependencies": { "@eslint/js": "^9.17.0", "@react-router/dev": "^7.1.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.1", "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.1", "@types/prismjs": "^1.26.5", @@ -88,6 +90,8 @@ "eslint-plugin-react-refresh": "^0.4.16", "fake-indexeddb": "^6.2.5", "globals": "^15.14.0", + "happy-dom": "^20.0.11", + "jsdom": "^27.4.0", "postcss": "^8.4.49", "prettier": "^3.7.4", "tailwindcss": "^3.4.17", diff --git a/src/components/pages/PreviewAddressPage.tsx b/src/components/pages/PreviewAddressPage.tsx index 23fe996..361dc30 100644 --- a/src/components/pages/PreviewAddressPage.tsx +++ b/src/components/pages/PreviewAddressPage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useParams, useNavigate } from "react-router"; +import { useNip19Decode } from "@/hooks/useNip19Decode"; import { nip19 } from "nostr-tools"; -import type { AddressPointer } from "nostr-tools/nip19"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -14,55 +14,86 @@ import { toast } from "sonner"; export default function PreviewAddressPage() { const { identifier } = useParams<{ identifier: string }>(); const navigate = useNavigate(); - const [error, setError] = useState(null); + // Reconstruct the full identifier + const fullIdentifier = identifier ? `naddr${identifier}` : undefined; + + // Decode the naddr identifier + const { decoded, isLoading, error, retry } = useNip19Decode( + fullIdentifier, + "naddr" + ); + + // Handle redirect when decoded successfully useEffect(() => { - if (!identifier) { - setError("No identifier provided"); - return; + if (!decoded || decoded.type !== "naddr") return; + + const pointer = decoded.data; + + // Check if it's a spellbook (kind 30777) + if (pointer.kind === 30777) { + // Redirect to the spellbook route + const npub = nip19.npubEncode(pointer.pubkey); + navigate(`/${npub}/${pointer.identifier}`, { replace: true }); + } else { + // For other kinds, we could extend this to handle them differently + // For now, show an error via toast + toast.error(`Addressable events of kind ${pointer.kind} are not yet supported in preview mode`); } + }, [decoded, navigate]); - // Reconstruct the full identifier - const fullIdentifier = `naddr${identifier}`; - - try { - const decoded = nip19.decode(fullIdentifier); - - if (decoded.type !== "naddr") { - setError(`Invalid identifier type: expected naddr, got ${decoded.type}`); - toast.error("Invalid naddr identifier"); - return; - } - - const pointer = decoded.data as AddressPointer; - - // Check if it's a spellbook (kind 30777) - if (pointer.kind === 30777) { - // Redirect to the spellbook route - const npub = nip19.npubEncode(pointer.pubkey); - navigate(`/${npub}/${pointer.identifier}`, { replace: true }); - } else { - // For other kinds, we could extend this to handle them differently - // For now, show an error - setError( - `Addressable events of kind ${pointer.kind} are not yet supported in preview mode` - ); - toast.error("Unsupported event kind"); - } - } catch (e) { - console.error("Failed to decode naddr:", e); - setError(e instanceof Error ? e.message : "Failed to decode identifier"); + // Show error toast when error occurs + useEffect(() => { + if (error) { toast.error("Invalid naddr identifier"); } - }, [identifier, navigate]); + }, [error]); - // Loading/error state + // Loading state + if (isLoading) { + return ( +
+ +
+

Redirecting...

+

Processing address pointer

+
+
+ ); + } + + // Error state if (error) { return (
-
+
{error}
+
+ + +
+
+ ); + } + + // Show error for unsupported kinds + if (decoded && decoded.type === "naddr" && decoded.data.kind !== 30777) { + return ( +
+
+ Addressable events of kind {decoded.data.kind} are not yet supported in preview mode +
+
-
); } return (
- +
); } diff --git a/src/components/pages/PreviewProfilePage.tsx b/src/components/pages/PreviewProfilePage.tsx index f153bb8..c961d6e 100644 --- a/src/components/pages/PreviewProfilePage.tsx +++ b/src/components/pages/PreviewProfilePage.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router"; -import { nip19 } from "nostr-tools"; +import { useNip19Decode } from "@/hooks/useNip19Decode"; import { ProfileViewer } from "../ProfileViewer"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { useEffect } from "react"; /** * PreviewProfilePage - Preview a Nostr profile from an npub identifier @@ -13,36 +13,25 @@ import { toast } from "sonner"; export default function PreviewProfilePage() { const { identifier } = useParams<{ identifier: string }>(); const navigate = useNavigate(); - const [pubkey, setPubkey] = useState(null); - const [error, setError] = useState(null); + // Reconstruct the full identifier (react-router splits on /) + const fullIdentifier = identifier ? `npub${identifier}` : undefined; + + // Decode the npub identifier + const { decoded, isLoading, error, retry } = useNip19Decode( + fullIdentifier, + "npub" + ); + + // Show error toast when error occurs useEffect(() => { - if (!identifier) { - setError("No identifier provided"); - return; - } - - // Reconstruct the full identifier (react-router splits on /) - const fullIdentifier = `npub${identifier}`; - - try { - const decoded = nip19.decode(fullIdentifier); - if (decoded.type !== "npub") { - setError(`Invalid identifier type: expected npub, got ${decoded.type}`); - toast.error("Invalid npub identifier"); - return; - } - - setPubkey(decoded.data); - } catch (e) { - console.error("Failed to decode npub:", e); - setError(e instanceof Error ? e.message : "Failed to decode identifier"); + if (error) { toast.error("Invalid npub identifier"); } - }, [identifier]); + }, [error]); // Loading state - if (!pubkey && !error) { + if (isLoading) { return (
@@ -58,22 +47,35 @@ export default function PreviewProfilePage() { if (error) { return (
-
+
{error}
- +
+ + +
); } + // Type guard to ensure we have the correct decoded type + if (!decoded || decoded.type !== "npub") { + return null; + } + return (
- +
); } diff --git a/src/hooks/useNip19Decode.test.ts b/src/hooks/useNip19Decode.test.ts new file mode 100644 index 0000000..4417e93 --- /dev/null +++ b/src/hooks/useNip19Decode.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { useNip19Decode } from "./useNip19Decode"; +import { nip19 } from "nostr-tools"; + +describe("useNip19Decode", () => { + // Test data + const testPubkey = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const testEventId = + "d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027"; + + describe("npub decoding", () => { + it("should decode valid npub identifier", async () => { + const npub = nip19.npubEncode(testPubkey); + const { result } = renderHook(() => useNip19Decode(npub)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded).toEqual({ + type: "npub", + data: testPubkey, + }); + expect(result.current.error).toBeNull(); + }); + + it("should validate expected type for npub", async () => { + const npub = nip19.npubEncode(testPubkey); + const { result } = renderHook(() => useNip19Decode(npub, "npub")); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded).toEqual({ + type: "npub", + data: testPubkey, + }); + expect(result.current.error).toBeNull(); + }); + + it("should error when expected type doesn't match", async () => { + const npub = nip19.npubEncode(testPubkey); + const { result } = renderHook(() => useNip19Decode(npub, "note")); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded).toBeNull(); + expect(result.current.error).toContain("expected note, got npub"); + }); + }); + + describe("note decoding", () => { + it("should decode valid note identifier", async () => { + const note = nip19.noteEncode(testEventId); + const { result } = renderHook(() => useNip19Decode(note)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded).toEqual({ + type: "note", + data: testEventId, + }); + expect(result.current.error).toBeNull(); + }); + }); + + describe("nevent decoding", () => { + it("should decode valid nevent identifier", async () => { + const nevent = nip19.neventEncode({ + id: testEventId, + relays: ["wss://relay.example.com"], + }); + const { result } = renderHook(() => useNip19Decode(nevent)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded?.type).toBe("nevent"); + expect(result.current.decoded?.data).toEqual({ + id: testEventId, + relays: ["wss://relay.example.com"], + }); + expect(result.current.error).toBeNull(); + }); + }); + + describe("naddr decoding", () => { + it("should decode valid naddr identifier", async () => { + const naddr = nip19.naddrEncode({ + kind: 30777, + pubkey: testPubkey, + identifier: "test-spellbook", + relays: ["wss://relay.example.com"], + }); + const { result } = renderHook(() => useNip19Decode(naddr)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded?.type).toBe("naddr"); + expect(result.current.decoded?.data).toEqual({ + kind: 30777, + pubkey: testPubkey, + identifier: "test-spellbook", + relays: ["wss://relay.example.com"], + }); + expect(result.current.error).toBeNull(); + }); + }); + + describe("error handling", () => { + it("should handle missing identifier", async () => { + const { result } = renderHook(() => useNip19Decode(undefined)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded).toBeNull(); + expect(result.current.error).toBe("No identifier provided"); + }); + + it("should handle invalid identifier format", async () => { + const { result } = renderHook(() => + useNip19Decode("invalid-identifier") + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded).toBeNull(); + expect(result.current.error).toBeTruthy(); + }); + + it("should handle corrupted bech32 string", async () => { + const { result } = renderHook(() => + useNip19Decode("npub1invalidbech32string") + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded).toBeNull(); + expect(result.current.error).toBeTruthy(); + }); + }); + + describe("retry functionality", () => { + it("should retry decoding when retry is called", async () => { + const { result } = renderHook(() => + useNip19Decode("invalid-identifier") + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBeTruthy(); + + // Call retry wrapped in act + act(() => { + result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Should still have error since the identifier is still invalid + expect(result.current.error).toBeTruthy(); + }); + }); + + describe("identifier changes", () => { + it("should reset state when identifier changes", async () => { + const npub = nip19.npubEncode(testPubkey); + const { result, rerender } = renderHook( + ({ id }: { id: string | undefined }) => useNip19Decode(id), + { + initialProps: { id: npub as string }, + } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded?.type).toBe("npub"); + + // Change to note + const note = nip19.noteEncode(testEventId); + rerender({ id: note }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.decoded?.type).toBe("note"); + expect(result.current.error).toBeNull(); + }); + }); +}); diff --git a/src/hooks/useNip19Decode.ts b/src/hooks/useNip19Decode.ts new file mode 100644 index 0000000..cbd476b --- /dev/null +++ b/src/hooks/useNip19Decode.ts @@ -0,0 +1,134 @@ +import { useEffect, useState } from "react"; +import { nip19 } from "nostr-tools"; +import type { + EventPointer, + AddressPointer, + ProfilePointer, +} from "nostr-tools/nip19"; + +/** + * Supported NIP-19 entity types for decoding + */ +export type Nip19EntityType = "npub" | "note" | "nevent" | "naddr" | "nprofile"; + +/** + * Decoded entity result with discriminated union for type safety + */ +export type DecodedEntity = + | { type: "npub"; data: string } + | { type: "note"; data: string } + | { type: "nevent"; data: EventPointer } + | { type: "naddr"; data: AddressPointer } + | { type: "nprofile"; data: ProfilePointer }; + +/** + * Hook result containing decoded data, loading, and error states + */ +export interface UseNip19DecodeResult { + /** Decoded entity (null while loading or on error) */ + decoded: DecodedEntity | null; + /** Loading state */ + isLoading: boolean; + /** Error message (null if no error) */ + error: string | null; + /** Retry the decode operation */ + retry: () => void; +} + +/** + * Hook to decode NIP-19 encoded entities (npub, note, nevent, naddr, nprofile) + * + * @param identifier - The NIP-19 encoded string (e.g., "npub1...") + * @param expectedType - Optional expected type for validation + * @returns Decoded entity with loading and error states + * + * @example + * ```tsx + * const { decoded, isLoading, error } = useNip19Decode(identifier, "npub"); + * if (isLoading) return ; + * if (error) return ; + * if (decoded?.type === "npub") { + * return ; + * } + * ``` + */ +export function useNip19Decode( + identifier: string | undefined, + expectedType?: Nip19EntityType +): UseNip19DecodeResult { + const [decoded, setDecoded] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [retryCount, setRetryCount] = useState(0); + + useEffect(() => { + // Reset state when identifier changes + setDecoded(null); + setError(null); + setIsLoading(true); + + if (!identifier) { + setError("No identifier provided"); + setIsLoading(false); + return; + } + + try { + const result = nip19.decode(identifier); + + // Validate expected type if provided + if (expectedType && result.type !== expectedType) { + setError( + `Invalid identifier type: expected ${expectedType}, got ${result.type}` + ); + setIsLoading(false); + return; + } + + // Map decoded result to typed entity + let entity: DecodedEntity; + + switch (result.type) { + case "npub": + entity = { type: "npub", data: result.data }; + break; + case "note": + entity = { type: "note", data: result.data }; + break; + case "nevent": + entity = { type: "nevent", data: result.data }; + break; + case "naddr": + entity = { type: "naddr", data: result.data }; + break; + case "nprofile": + entity = { type: "nprofile", data: result.data }; + break; + default: + setError(`Unsupported entity type: ${result.type}`); + setIsLoading(false); + return; + } + + setDecoded(entity); + setIsLoading(false); + } catch (e) { + console.error("Failed to decode NIP-19 identifier:", identifier, e); + const errorMessage = + e instanceof Error ? e.message : "Failed to decode identifier"; + setError(errorMessage); + setIsLoading(false); + } + }, [identifier, expectedType, retryCount]); + + const retry = () => { + setRetryCount((prev) => prev + 1); + }; + + return { + decoded, + isLoading, + error, + retry, + }; +}