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
This commit is contained in:
Claude
2026-01-04 19:01:56 +00:00
parent 5fca0f9316
commit d912d5fce7
5 changed files with 71 additions and 66 deletions

View File

@@ -0,0 +1,34 @@
import { useParams } from "react-router";
import PreviewProfilePage from "./PreviewProfilePage";
import PreviewEventPage from "./PreviewEventPage";
import PreviewAddressPage from "./PreviewAddressPage";
/**
* Nip19PreviewRouter - Routes to the appropriate preview component based on NIP-19 identifier type
* Handles npub, note, nevent, and naddr identifiers
*/
export default function Nip19PreviewRouter() {
const { identifier } = useParams<{ identifier: string }>();
if (!identifier) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">No identifier provided</p>
</div>
);
}
// Route based on identifier prefix
if (identifier.startsWith("npub1")) {
return <PreviewProfilePage />;
} else if (identifier.startsWith("nevent1")) {
return <PreviewEventPage />;
} else if (identifier.startsWith("note1")) {
return <PreviewEventPage />;
} else if (identifier.startsWith("naddr1")) {
return <PreviewAddressPage />;
}
// Not a recognized NIP-19 identifier
return null;
}

View File

@@ -7,19 +7,16 @@ import { toast } from "sonner";
/**
* PreviewAddressPage - Preview or redirect naddr identifiers
* Route: /naddr1*
* Route: /:identifier (where identifier starts with naddr1)
* For spellbooks (kind 30777), redirects to /:actor/:identifier
* For all other addressable events, shows detail view
*/
export default function PreviewAddressPage() {
const params = useParams<{ "*": string }>();
const { identifier } = useParams<{ identifier: string }>();
const navigate = useNavigate();
// Get the full naddr from the URL (naddr1 + captured part)
const fullIdentifier = params["*"] ? `naddr1${params["*"]}` : undefined;
// Decode the naddr identifier (synchronous, memoized)
const { decoded, error } = useNip19Decode(fullIdentifier, "naddr");
const { decoded, error } = useNip19Decode(identifier, "naddr");
// Handle redirect for spellbooks
useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { useMemo, useEffect } from "react";
import { useParams, useNavigate, useLocation } from "react-router";
import { useParams, useNavigate } from "react-router";
import { useNip19Decode } from "@/hooks/useNip19Decode";
import type { EventPointer } from "nostr-tools/nip19";
import { EventDetailViewer } from "../EventDetailViewer";
@@ -7,30 +7,15 @@ import { toast } from "sonner";
/**
* PreviewEventPage - Preview a Nostr event from a nevent or note identifier
* Routes: /nevent1*, /note1*
* Route: /:identifier (where identifier starts with nevent1 or note1)
* This page shows a single event view without affecting user's workspace layout
*/
export default function PreviewEventPage() {
const params = useParams<{ "*": string }>();
const { identifier } = useParams<{ identifier: string }>();
const navigate = useNavigate();
const location = useLocation();
// Determine the prefix based on the current path and reconstruct full identifier
const fullIdentifier = useMemo(() => {
const captured = params["*"];
if (!captured) return undefined;
const path = location.pathname;
if (path.startsWith("/nevent1")) {
return `nevent1${captured}`;
} else if (path.startsWith("/note1")) {
return `note1${captured}`;
}
return undefined;
}, [params, location.pathname]);
// Decode the event identifier (synchronous, memoized)
const { decoded, error } = useNip19Decode(fullIdentifier);
const { decoded, error } = useNip19Decode(identifier);
// Convert decoded entity to EventPointer
const pointer: EventPointer | null = useMemo(() => {

View File

@@ -6,18 +6,15 @@ import { toast } from "sonner";
/**
* PreviewProfilePage - Preview a Nostr profile from an npub identifier
* Route: /npub1*
* Route: /:identifier (where identifier starts with npub1)
* This page shows a single profile view without affecting user's workspace layout
*/
export default function PreviewProfilePage() {
const params = useParams<{ "*": string }>();
const { identifier } = useParams<{ identifier: string }>();
const navigate = useNavigate();
// Get the full npub from the URL (npub1 + captured part)
const fullIdentifier = params["*"] ? `npub1${params["*"]}` : undefined;
// Decode the npub identifier (synchronous, memoized)
const { decoded, error } = useNip19Decode(fullIdentifier, "npub");
const { decoded, error } = useNip19Decode(identifier, "npub");
// Show error toast when error occurs
useEffect(() => {

View File

@@ -2,9 +2,7 @@ import { createBrowserRouter, RouterProvider } from "react-router";
import { AppShell } from "./components/layouts/AppShell";
import DashboardPage from "./components/pages/DashboardPage";
import SpellbookPage from "./components/pages/SpellbookPage";
import PreviewProfilePage from "./components/pages/PreviewProfilePage";
import PreviewEventPage from "./components/pages/PreviewEventPage";
import PreviewAddressPage from "./components/pages/PreviewAddressPage";
import Nip19PreviewRouter from "./components/pages/Nip19PreviewRouter";
const router = createBrowserRouter([
{
@@ -15,38 +13,6 @@ const router = createBrowserRouter([
</AppShell>
),
},
{
path: "/npub1*",
element: (
<AppShell hideBottomBar>
<PreviewProfilePage />
</AppShell>
),
},
{
path: "/nevent1*",
element: (
<AppShell hideBottomBar>
<PreviewEventPage />
</AppShell>
),
},
{
path: "/note1*",
element: (
<AppShell hideBottomBar>
<PreviewEventPage />
</AppShell>
),
},
{
path: "/naddr1*",
element: (
<AppShell hideBottomBar>
<PreviewAddressPage />
</AppShell>
),
},
{
path: "/preview/:actor/:identifier",
element: (
@@ -55,6 +21,32 @@ const router = createBrowserRouter([
</AppShell>
),
},
// NIP-19 identifier preview route - must come before /:actor/:identifier catch-all
{
path: "/:identifier",
element: (
<AppShell hideBottomBar>
<Nip19PreviewRouter />
</AppShell>
),
// Only match single-segment paths that look like NIP-19 identifiers
loader: ({ params }) => {
const id = params.identifier;
if (
!id ||
!(
id.startsWith("npub1") ||
id.startsWith("note1") ||
id.startsWith("nevent1") ||
id.startsWith("naddr1")
)
) {
throw new Response("Not Found", { status: 404 });
}
return null;
},
},
// Catch-all for two-segment paths (spellbooks, etc.)
{
path: "/:actor/:identifier",
element: (