mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
* feat: add preview routes for Nostr identifiers (npub, nevent, note, naddr)
This commit adds dedicated preview routes for Nostr identifiers at the root level:
- /npub... - Shows a single profile view for npub identifiers
- /nevent... - Shows a single event detail view for nevent identifiers
- /note... - Shows a single event detail view for note identifiers
- /naddr... - Redirects spellbooks (kind 30777) to /:actor/:identifier route
Key changes:
- Created PreviewProfilePage component for npub identifiers
- Created PreviewEventPage component for nevent/note identifiers
- Created PreviewAddressPage component for naddr redirects
- Added hideBottomBar prop to AppShell to hide tabs in preview mode
- Added routes to root.tsx for all identifier types
Preview pages don't show bottom tabs and don't affect user's workspace layout.
* chore: update package-lock.json
* refactor: create reusable useNip19Decode hook and improve preview pages
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
* refactor: simplify useNip19Decode to synchronous with memoization
NIP-19 decoding is synchronous - removed unnecessary async complexity:
Hook changes (src/hooks/useNip19Decode.ts):
- Removed loading states (isLoading, setIsLoading)
- Removed retry functionality (unnecessary for sync operations)
- Now uses useMemo for efficient memoization
- Returns { decoded, error } instead of { decoded, isLoading, error, retry }
- Same string always yields same result (memoized)
- Went from ~120 lines to ~115 lines, but much simpler
Preview page changes:
- Removed loading spinners and states
- Removed retry buttons
- Simplified error handling
- Cleaner, more readable code
- PreviewProfilePage: 55 lines (down from 81)
- PreviewEventPage: 83 lines (down from 105)
- PreviewAddressPage: 83 lines (down from 117)
Test changes (src/hooks/useNip19Decode.test.ts):
- Removed waitFor and async/await (not needed for sync)
- Tests run faster (39ms vs 77ms - 49% improvement)
- Added memoization tests to verify caching works
- Simplified from 11 async tests to 11 sync tests
- All 11 tests passing ✓
Benefits:
- Simpler mental model: decode happens instantly
- Better performance: no state updates, just memoization
- Easier to test: synchronous tests are simpler
- More correct: matches the actual synchronous nature of nip19.decode()
- Less code: removed ~150 lines of unnecessary complexity
* feat: show detail view for all addressable events in naddr preview
Previously, PreviewAddressPage only handled spellbooks (kind 30777) and
showed errors for other addressable events. Now:
- Spellbooks (kind 30777): Redirect to /:actor/:identifier (existing behavior)
- All other addressable events: Show in EventDetailViewer
This enables previewing any addressable event (long-form articles, live
events, community posts, etc.) via naddr links.
Changes:
- Import EventDetailViewer
- Removed error state for non-spellbook kinds
- Show EventDetailViewer with AddressPointer for all other kinds
- Simplified from 83 lines to 77 lines
* fix: correct route patterns for NIP-19 identifier previews
The previous route patterns (/npub:identifier) conflicted with the catch-all
/:actor/:identifier route and didn't properly match NIP-19 identifiers.
Fixed by:
1. Using wildcard routes with correct prefixes:
- /npub1* (not /npub:identifier)
- /nevent1* (not /nevent:identifier)
- /note1* (not /note:identifier)
- /naddr1* (not /naddr:identifier)
2. Updated preview components to use params['*'] for wildcard capture:
- Reconstruct full identifier as prefix + captured part
- e.g., npub1 + params['*'] = npub107jk7htfv...
This ensures routes properly match before the catch-all /:actor/:identifier
route and correctly capture the full bech32-encoded identifier.
Test URL: /npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg
* style: apply prettier formatting
* fix: use loader-based routing for NIP-19 identifiers in React Router v7
Previous attempts using wildcard routes didn't work properly in React Router v7.
Solution:
- Single /:identifier route with a loader that validates NIP-19 prefixes
- Loader throws 404 if identifier doesn't start with npub1/note1/nevent1/naddr1
- Created Nip19PreviewRouter component that routes to correct preview page
- Routes are properly ordered: /:identifier before /:actor/:identifier catch-all
This ensures /npub107jk... routes to profile preview, not spellbook route.
Benefits:
- Simpler routing configuration (1 route vs 4 duplicate routes)
- Proper validation via loader
- Clean separation of concerns with router component
- Works correctly in React Router v7
---------
Co-authored-by: Claude <noreply@anthropic.com>
164 lines
5.0 KiB
TypeScript
164 lines
5.0 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { renderHook } 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", () => {
|
|
const npub = nip19.npubEncode(testPubkey);
|
|
const { result } = renderHook(() => useNip19Decode(npub));
|
|
|
|
expect(result.current.decoded).toEqual({
|
|
type: "npub",
|
|
data: testPubkey,
|
|
});
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
|
|
it("should validate expected type for npub", () => {
|
|
const npub = nip19.npubEncode(testPubkey);
|
|
const { result } = renderHook(() => useNip19Decode(npub, "npub"));
|
|
|
|
expect(result.current.decoded).toEqual({
|
|
type: "npub",
|
|
data: testPubkey,
|
|
});
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
|
|
it("should error when expected type doesn't match", () => {
|
|
const npub = nip19.npubEncode(testPubkey);
|
|
const { result } = renderHook(() => useNip19Decode(npub, "note"));
|
|
|
|
expect(result.current.decoded).toBeNull();
|
|
expect(result.current.error).toContain("expected note, got npub");
|
|
});
|
|
});
|
|
|
|
describe("note decoding", () => {
|
|
it("should decode valid note identifier", () => {
|
|
const note = nip19.noteEncode(testEventId);
|
|
const { result } = renderHook(() => useNip19Decode(note));
|
|
|
|
expect(result.current.decoded).toEqual({
|
|
type: "note",
|
|
data: testEventId,
|
|
});
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("nevent decoding", () => {
|
|
it("should decode valid nevent identifier", () => {
|
|
const nevent = nip19.neventEncode({
|
|
id: testEventId,
|
|
relays: ["wss://relay.example.com"],
|
|
});
|
|
const { result } = renderHook(() => useNip19Decode(nevent));
|
|
|
|
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", () => {
|
|
const naddr = nip19.naddrEncode({
|
|
kind: 30777,
|
|
pubkey: testPubkey,
|
|
identifier: "test-spellbook",
|
|
relays: ["wss://relay.example.com"],
|
|
});
|
|
const { result } = renderHook(() => useNip19Decode(naddr));
|
|
|
|
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", () => {
|
|
const { result } = renderHook(() => useNip19Decode(undefined));
|
|
|
|
expect(result.current.decoded).toBeNull();
|
|
expect(result.current.error).toBe("No identifier provided");
|
|
});
|
|
|
|
it("should handle invalid identifier format", () => {
|
|
const { result } = renderHook(() => useNip19Decode("invalid-identifier"));
|
|
|
|
expect(result.current.decoded).toBeNull();
|
|
expect(result.current.error).toBeTruthy();
|
|
});
|
|
|
|
it("should handle corrupted bech32 string", () => {
|
|
const { result } = renderHook(() =>
|
|
useNip19Decode("npub1invalidbech32string"),
|
|
);
|
|
|
|
expect(result.current.decoded).toBeNull();
|
|
expect(result.current.error).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("memoization", () => {
|
|
it("should memoize results for same identifier", () => {
|
|
const npub = nip19.npubEncode(testPubkey);
|
|
const { result, rerender } = renderHook(
|
|
({ id }: { id: string | undefined }) => useNip19Decode(id),
|
|
{
|
|
initialProps: { id: npub as string },
|
|
},
|
|
);
|
|
|
|
const firstResult = result.current;
|
|
expect(firstResult.decoded?.type).toBe("npub");
|
|
|
|
// Rerender with same identifier
|
|
rerender({ id: npub as string });
|
|
|
|
// Should return exact same object reference (memoized)
|
|
expect(result.current).toBe(firstResult);
|
|
});
|
|
|
|
it("should recompute when identifier changes", () => {
|
|
const npub = nip19.npubEncode(testPubkey);
|
|
const { result, rerender } = renderHook(
|
|
({ id }: { id: string | undefined }) => useNip19Decode(id),
|
|
{
|
|
initialProps: { id: npub as string },
|
|
},
|
|
);
|
|
|
|
expect(result.current.decoded?.type).toBe("npub");
|
|
|
|
// Change to note
|
|
const note = nip19.noteEncode(testEventId);
|
|
rerender({ id: note });
|
|
|
|
expect(result.current.decoded?.type).toBe("note");
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
});
|
|
});
|