From 48ce35cbeadf671ba6dc3bd69589df44889df627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Fri, 27 Mar 2026 14:27:28 +0100 Subject: [PATCH] feat: nip-5a nsites --- .../nostr/kinds/NsiteDetailRenderer.tsx | 243 ++++++++++++++++++ src/components/nostr/kinds/NsiteRenderer.tsx | 125 +++++++++ src/components/nostr/kinds/index.tsx | 16 ++ src/hooks/useNsiteMetadata.ts | 226 ++++++++++++++++ src/lib/nip5a-helpers.ts | 133 ++++++++++ src/services/db.ts | 26 ++ 6 files changed, 769 insertions(+) create mode 100644 src/components/nostr/kinds/NsiteDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/NsiteRenderer.tsx create mode 100644 src/hooks/useNsiteMetadata.ts create mode 100644 src/lib/nip5a-helpers.ts diff --git a/src/components/nostr/kinds/NsiteDetailRenderer.tsx b/src/components/nostr/kinds/NsiteDetailRenderer.tsx new file mode 100644 index 0000000..bb5d9a4 --- /dev/null +++ b/src/components/nostr/kinds/NsiteDetailRenderer.tsx @@ -0,0 +1,243 @@ +import { useState } from "react"; +import { + Globe, + FileText, + HardDrive, + ExternalLink, + Code, + Radio, +} from "lucide-react"; +import { + getNsitePaths, + getNsiteServers, + getNsiteRelays, + getNsiteIdentifier, + getNsiteSource, + getNsiteGatewayUrl, +} from "@/lib/nip5a-helpers"; +import { useNsiteMetadata } from "@/hooks/useNsiteMetadata"; +import { useAddWindow } from "@/core/state"; +import { RelayLink } from "../RelayLink"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import type { NostrEvent } from "@/types/nostr"; + +function NsiteIcon({ + faviconUrl, + className, +}: { + faviconUrl?: string; + className?: string; +}) { + const [failed, setFailed] = useState(false); + + if (!faviconUrl || failed) { + return ; + } + + return ( + setFailed(true)} + /> + ); +} + +function NsiteFileRow({ + path, + hash, + serverUrl, +}: { + path: string; + hash: string; + serverUrl?: string; +}) { + const addWindow = useAddWindow(); + + const handleClick = () => { + addWindow( + "blossom", + { subcommand: "blob", sha256: hash, serverUrl }, + `blossom blob ${hash.slice(0, 8)}`, + undefined, + ); + }; + + return ( +
+ {path} + + {hash.slice(0, 8)}…{hash.slice(-4)} + +
+ ); +} + +/** + * Shared detail view for all nsite kinds + */ +function NsiteDetailView({ + event, + legacy = false, +}: { + event: NostrEvent; + legacy?: boolean; +}) { + const paths = getNsitePaths(event); + const servers = getNsiteServers(event); + const relays = getNsiteRelays(event); + const identifier = getNsiteIdentifier(event); + const source = getNsiteSource(event); + const gatewayUrl = getNsiteGatewayUrl(event); + const { title, description, faviconUrl } = useNsiteMetadata(event); + const addWindow = useAddWindow(); + + const displayTitle = title || (identifier ? `/${identifier}` : "Nsite"); + + const handleServerClick = (serverUrl: string) => { + addWindow( + "blossom", + { subcommand: "server", serverUrl }, + `blossom server ${serverUrl}`, + undefined, + ); + }; + + return ( +
+ {/* Header */} +
+
+ +

{displayTitle}

+ {identifier && } +
+ {description && ( +

{description}

+ )} + {legacy && ( +

+ This is a legacy nsite event (kind 34128). New sites should use kind + 15128 or 35128. +

+ )} +
+ + {/* Actions */} +
+ + {source && ( + + )} +
+ + {/* Files */} + {paths.length > 0 && ( +
+
+ + Files ({paths.length}) +
+
+ {[...paths] + .sort((a, b) => a.path.localeCompare(b.path)) + .map(({ path, hash }) => ( + + ))} +
+
+ )} + + {/* Servers */} + {servers.length > 0 && ( +
+
+ + Blossom Servers ({servers.length}) +
+
+ {servers.map((url) => ( +
handleServerClick(url)} + > + + + {url} + + +
+ ))} +
+
+ )} + + {/* Relays */} + {relays.length > 0 && ( +
+
+ + Relays ({relays.length}) +
+
+ {relays.map((url) => ( + + ))} +
+
+ )} +
+ ); +} + +/** + * Kind 15128 Detail Renderer - Root Nsite Manifest + */ +export function NsiteRootDetailRenderer({ event }: { event: NostrEvent }) { + return ; +} + +/** + * Kind 35128 Detail Renderer - Named Nsite Manifest + */ +export function NsiteNamedDetailRenderer({ event }: { event: NostrEvent }) { + return ; +} + +/** + * Kind 34128 Detail Renderer - Legacy Nsite (deprecated) + */ +export function NsiteLegacyDetailRenderer({ event }: { event: NostrEvent }) { + return ; +} diff --git a/src/components/nostr/kinds/NsiteRenderer.tsx b/src/components/nostr/kinds/NsiteRenderer.tsx new file mode 100644 index 0000000..69e9148 --- /dev/null +++ b/src/components/nostr/kinds/NsiteRenderer.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { Globe, FileText, HardDrive, ExternalLink } from "lucide-react"; +import { + getNsitePaths, + getNsiteServers, + getNsiteIdentifier, + getNsiteGatewayUrl, +} from "@/lib/nip5a-helpers"; +import { useNsiteMetadata } from "@/hooks/useNsiteMetadata"; +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; + +/** + * Shows favicon with Globe fallback on error or when unavailable + */ +function NsiteIcon({ + faviconUrl, + className, +}: { + faviconUrl?: string; + className?: string; +}) { + const [failed, setFailed] = useState(false); + + if (!faviconUrl || failed) { + return ; + } + + return ( + setFailed(true)} + /> + ); +} + +/** + * Shared nsite feed renderer + */ +function NsiteRendererInner({ + event, + legacy = false, +}: BaseEventProps & { legacy?: boolean }) { + const paths = getNsitePaths(event); + const servers = getNsiteServers(event); + const identifier = getNsiteIdentifier(event); + const { title, faviconUrl } = useNsiteMetadata(event); + const gatewayUrl = getNsiteGatewayUrl(event); + + const displayTitle = title || (identifier ? `/${identifier}` : "Nsite"); + + return ( + +
+ + + {displayTitle} + {legacy && ( + + (legacy) + + )} + + +
+ {paths.length > 0 && ( +
+ + + {paths.length} file{paths.length !== 1 ? "s" : ""} + +
+ )} + {servers.length > 0 && ( +
+ + + {servers.length} server{servers.length !== 1 ? "s" : ""} + +
+ )} + e.stopPropagation()} + > + + Visit + +
+
+
+ ); +} + +/** + * Kind 15128 Renderer - Root Nsite Manifest (Feed View) + */ +export function NsiteRootRenderer({ event }: BaseEventProps) { + return ; +} + +/** + * Kind 35128 Renderer - Named Nsite Manifest (Feed View) + */ +export function NsiteNamedRenderer({ event }: BaseEventProps) { + return ; +} + +/** + * Kind 34128 Renderer - Legacy Nsite (Feed View, deprecated) + */ +export function NsiteLegacyRenderer({ event }: BaseEventProps) { + return ; +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index b60bb85..ca56cc2 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -176,6 +176,16 @@ import { import { PlaylistRenderer, PlaylistDetailRenderer } from "./PlaylistRenderer"; import { EducationalResourceRenderer } from "./EducationalResourceRenderer"; import { EducationalResourceDetailRenderer } from "./EducationalResourceDetailRenderer"; +import { + NsiteRootRenderer, + NsiteNamedRenderer, + NsiteLegacyRenderer, +} from "./NsiteRenderer"; +import { + NsiteRootDetailRenderer, + NsiteNamedDetailRenderer, + NsiteLegacyDetailRenderer, +} from "./NsiteDetailRenderer"; /** * Registry of kind-specific renderers @@ -240,6 +250,7 @@ const kindRenderers: Record> = { 10317: Kind10317Renderer, // User Grasp List (NIP-34) 10777: FavoriteSpellsRenderer, // Favorite Spells (Grimoire) 13534: RelayMembersRenderer, // Relay Members (NIP-43) + 15128: NsiteRootRenderer, // Root Nsite Manifest (NIP-5A) 30000: FollowSetRenderer, // Follow Sets (NIP-51) 30002: GenericRelayListRenderer, // Relay Sets (NIP-51) 30003: BookmarkSetRenderer, // Bookmark Sets (NIP-51) @@ -265,6 +276,8 @@ const kindRenderers: Record> = { 34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy) 34236: Kind22Renderer, // Vertical Video (NIP-71 legacy) 36787: MusicTrackRenderer, // Music Track + 34128: NsiteLegacyRenderer, // Legacy Nsite (NIP-5A, deprecated) + 35128: NsiteNamedRenderer, // Named Nsite Manifest (NIP-5A) 30617: RepositoryRenderer, // Repository (NIP-34) 30618: RepositoryStateRenderer, // Repository State (NIP-34) 30777: SpellbookRenderer, // Spellbook (Grimoire) @@ -356,6 +369,7 @@ const detailRenderers: Record< 10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34) 10777: FavoriteSpellsDetailRenderer, // Favorite Spells Detail (Grimoire) 13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43) + 15128: NsiteRootDetailRenderer, // Root Nsite Manifest Detail (NIP-5A) 30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51) 30003: BookmarkSetDetailRenderer, // Bookmark Sets Detail (NIP-51) 30004: ArticleCurationSetDetailRenderer, // Article Curation Sets Detail (NIP-51) @@ -376,6 +390,8 @@ const detailRenderers: Record< 30383: TrustedAssertionDetailRenderer, // Event Assertion Detail (NIP-85) 30384: TrustedAssertionDetailRenderer, // Address Assertion Detail (NIP-85) 30385: TrustedAssertionDetailRenderer, // External Assertion Detail (NIP-85) + 34128: NsiteLegacyDetailRenderer, // Legacy Nsite Detail (NIP-5A, deprecated) + 35128: NsiteNamedDetailRenderer, // Named Nsite Detail (NIP-5A) 30617: RepositoryDetailRenderer, // Repository Detail (NIP-34) 30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34) 30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire) diff --git a/src/hooks/useNsiteMetadata.ts b/src/hooks/useNsiteMetadata.ts new file mode 100644 index 0000000..21ca1dc --- /dev/null +++ b/src/hooks/useNsiteMetadata.ts @@ -0,0 +1,226 @@ +import { useState, useEffect } from "react"; +import type { NostrEvent } from "@/types/nostr"; +import { + getNsiteTitle, + getNsiteDescription, + getNsiteIndexHash, + getNsiteServers, + getNsiteGatewayUrl, +} from "@/lib/nip5a-helpers"; +import { getBlobUrl } from "@/services/blossom"; +import { blossomServerCache } from "@/services/blossom-server-cache"; +import db from "@/services/db"; + +export interface NsiteMetadata { + title?: string; + description?: string; + faviconUrl?: string; +} + +// In-memory fast layer (populated from Dexie on first access) +const memoryCache = new Map(); + +// Track in-flight fetches to avoid duplicate requests +const pendingFetches = new Map>(); + +async function getCachedMetadata(hash: string): Promise { + // Memory first + const mem = memoryCache.get(hash); + if (mem) return mem; + + // Then Dexie + try { + const cached = await db.nsiteMetadata.get(hash); + if (cached) { + const metadata: NsiteMetadata = { + title: cached.title, + description: cached.description, + faviconUrl: cached.faviconUrl, + }; + memoryCache.set(hash, metadata); + return metadata; + } + } catch { + // Dexie error, continue to fetch + } + + return null; +} + +async function persistMetadata( + hash: string, + metadata: NsiteMetadata, +): Promise { + memoryCache.set(hash, metadata); + try { + await db.nsiteMetadata.put({ hash, ...metadata }); + } catch { + // Non-critical, memory cache is enough + } +} + +async function fetchAndParseIndexHtml( + hash: string, + servers: string[], + baseUrl: string, + signal: AbortSignal, +): Promise { + for (const server of servers) { + try { + const url = getBlobUrl(server, hash); + const response = await fetch(url, { signal }); + if (!response.ok) continue; + + const html = await response.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + // Resolve favicon: try , then + let faviconUrl: string | undefined; + const iconLink = + doc.querySelector('link[rel="icon"]') ?? + doc.querySelector('link[rel="shortcut icon"]'); + const iconHref = iconLink?.getAttribute("href"); + if (iconHref) { + if (iconHref.startsWith("data:")) { + faviconUrl = iconHref; + } else { + try { + faviconUrl = new URL(iconHref, baseUrl).href; + } catch { + // Invalid URL, skip + } + } + } + + const metadata: NsiteMetadata = { + title: doc.querySelector("title")?.textContent?.trim() || undefined, + description: + doc + .querySelector('meta[name="description"]') + ?.getAttribute("content") + ?.trim() || undefined, + faviconUrl, + }; + + return metadata; + } catch { + if (signal.aborted) return null; + // Try next server + } + } + return null; +} + +/** + * Hook that returns site metadata for an nsite manifest event. + * + * Strategy (progressive): + * 1. Use title/description tags from the event if present + * 2. Check in-memory cache, then Dexie (persistent across sessions) + * 3. Fetch index.html from Blossom, parse for title/description/favicon + * 4. Persist to both caches (keyed by sha256 hash — immutable, no TTL) + */ +export function useNsiteMetadata(event: NostrEvent): { + title?: string; + description?: string; + faviconUrl?: string; + loading: boolean; +} { + const tagTitle = getNsiteTitle(event); + const tagDescription = getNsiteDescription(event); + const gatewayUrl = getNsiteGatewayUrl(event); + + const [fetched, setFetched] = useState(() => { + // Sync init from memory cache + const indexHash = getNsiteIndexHash(event); + return indexHash ? (memoryCache.get(indexHash) ?? null) : null; + }); + const [loading, setLoading] = useState(false); + + const indexHash = getNsiteIndexHash(event); + const needsFetch = !!indexHash; + + useEffect(() => { + if (!needsFetch || !indexHash) return; + + // Already have it in memory + const mem = memoryCache.get(indexHash); + if (mem) { + setFetched(mem); + return; + } + + const controller = new AbortController(); + let cancelled = false; + setLoading(true); + + (async () => { + // Check Dexie + const cached = await getCachedMetadata(indexHash); + if (cached) { + if (!cancelled) { + setFetched(cached); + setLoading(false); + } + return; + } + + // Deduplicate in-flight fetches + let fetchPromise = pendingFetches.get(indexHash); + if (!fetchPromise) { + fetchPromise = (async () => { + const eventServers = getNsiteServers(event); + const authorServers = + (await blossomServerCache.getServers(event.pubkey)) ?? []; + const allServers = [...new Set([...eventServers, ...authorServers])]; + + if (allServers.length === 0) return null; + + return fetchAndParseIndexHtml( + indexHash, + allServers, + gatewayUrl, + controller.signal, + ); + })(); + pendingFetches.set(indexHash, fetchPromise); + } + + try { + const result = await fetchPromise; + pendingFetches.delete(indexHash); + if (cancelled) return; + if (result) { + await persistMetadata(indexHash, result); + setFetched(result); + } + } catch { + pendingFetches.delete(indexHash); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [needsFetch, indexHash, event, gatewayUrl]); + + if (tagTitle) { + return { + title: tagTitle, + description: tagDescription, + faviconUrl: fetched?.faviconUrl, + loading: false, + }; + } + + return { + title: fetched?.title, + description: fetched?.description ?? tagDescription, + faviconUrl: fetched?.faviconUrl, + loading, + }; +} diff --git a/src/lib/nip5a-helpers.ts b/src/lib/nip5a-helpers.ts new file mode 100644 index 0000000..9ff0da5 --- /dev/null +++ b/src/lib/nip5a-helpers.ts @@ -0,0 +1,133 @@ +import type { NostrEvent } from "@/types/nostr"; +import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers"; +import { nip19 } from "nostr-tools"; + +/** + * NIP-5A Helper Functions + * Utility functions for parsing NIP-5A pubkey static website events + * + * All helper functions use applesauce's getOrComputeCachedValue to cache + * computed values on the event object itself. This means you don't need + * useMemo when calling these functions. + */ + +export const DEFAULT_NSITE_GATEWAY = "nsite.lol"; + +// Cache symbols +const NsitePathsSymbol = Symbol("nsitePaths"); +const NsiteServersSymbol = Symbol("nsiteServers"); +const NsiteRelaysSymbol = Symbol("nsiteRelays"); +const NsiteGatewayUrlSymbol = Symbol("nsiteGatewayUrl"); + +export interface NsitePath { + path: string; + hash: string; +} + +/** + * Get the site title from a site manifest event + */ +export function getNsiteTitle(event: NostrEvent): string | undefined { + return getTagValue(event, "title"); +} + +/** + * Get the site description from a site manifest event + */ +export function getNsiteDescription(event: NostrEvent): string | undefined { + return getTagValue(event, "description"); +} + +/** + * Get the source code URL from a site manifest event + */ +export function getNsiteSource(event: NostrEvent): string | undefined { + return getTagValue(event, "source"); +} + +/** + * Get all path-to-hash mappings from a site manifest event + */ +export function getNsitePaths(event: NostrEvent): NsitePath[] { + return getOrComputeCachedValue(event, NsitePathsSymbol, () => + event.tags + .filter((t) => t[0] === "path" && t[1] && t[2]) + .map((t) => ({ path: t[1], hash: t[2] })), + ); +} + +/** + * Get all blossom server hints from a site manifest event + */ +export function getNsiteServers(event: NostrEvent): string[] { + return getOrComputeCachedValue(event, NsiteServersSymbol, () => [ + ...new Set( + event.tags.filter((t) => t[0] === "server" && t[1]).map((t) => t[1]), + ), + ]); +} + +/** + * Get relay hints from a site manifest event + */ +export function getNsiteRelays(event: NostrEvent): string[] { + return getOrComputeCachedValue(event, NsiteRelaysSymbol, () => [ + ...new Set( + event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]), + ), + ]); +} + +/** + * Get the sha256 hash for /index.html from the site manifest + */ +export function getNsiteIndexHash(event: NostrEvent): string | undefined { + const paths = getNsitePaths(event); + return paths.find((p) => p.path === "/index.html")?.hash; +} + +/** + * Get the sha256 hash for /favicon.ico from the site manifest + */ +export function getNsiteFaviconHash(event: NostrEvent): string | undefined { + const paths = getNsitePaths(event); + return paths.find((p) => p.path === "/favicon.ico")?.hash; +} + +/** + * Get the site identifier (d tag) for named sites (kind 35128) + */ +export function getNsiteIdentifier(event: NostrEvent): string | undefined { + return getTagValue(event, "d"); +} + +/** + * Convert a hex pubkey to base36 (50 chars, zero-padded) + * Used for named site subdomain construction per NIP-5A spec + */ +function hexToBase36(hex: string): string { + const num = BigInt("0x" + hex); + return num.toString(36).padStart(50, "0"); +} + +/** + * Get the gateway URL to view this site + * Root sites (15128): https://.nsite.lol + * Named sites (35128): https://.nsite.lol + */ +export function getNsiteGatewayUrl( + event: NostrEvent, + gateway: string = DEFAULT_NSITE_GATEWAY, +): string { + return getOrComputeCachedValue(event, NsiteGatewayUrlSymbol, () => { + if (event.kind === 35128) { + const dTag = getNsiteIdentifier(event); + if (dTag) { + const pubkeyB36 = hexToBase36(event.pubkey); + return `https://${pubkeyB36}${dTag}.${gateway}`; + } + } + const npub = nip19.npubEncode(event.pubkey); + return `https://${npub}.${gateway}`; + }); +} diff --git a/src/services/db.ts b/src/services/db.ts index b84eeec..ab9d330 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -100,6 +100,13 @@ export interface LnurlCache { fetchedAt: number; // Timestamp for cache invalidation } +export interface CachedNsiteMetadata { + hash: string; // Primary key - sha256 of index.html (content-addressable, never expires) + title?: string; + description?: string; + faviconUrl?: string; +} + export interface GrimoireZap { eventId: string; // Primary key - zap receipt event ID senderPubkey: string; // Who sent the zap @@ -120,6 +127,7 @@ class GrimoireDb extends Dexie { spells!: Table; spellbooks!: Table; lnurlCache!: Table; + nsiteMetadata!: Table; grimoireZaps!: Table; constructor(name: string) { @@ -366,6 +374,24 @@ class GrimoireDb extends Dexie { grimoireZaps: "&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]", }); + + // Version 18: Add nsite metadata caching (NIP-5A) + this.version(18).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + lnurlCache: "&address, fetchedAt", + grimoireZaps: + "&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]", + nsiteMetadata: "&hash", + }); } }