mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-08 14:37:04 +02:00
feat: nip-5a nsites
This commit is contained in:
243
src/components/nostr/kinds/NsiteDetailRenderer.tsx
Normal file
243
src/components/nostr/kinds/NsiteDetailRenderer.tsx
Normal file
@@ -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 <Globe className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className={`${className} object-contain`}
|
||||
onError={() => 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 (
|
||||
<div
|
||||
className="flex items-center gap-2 py-0.5 px-1 -mx-1 text-xs rounded hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="truncate flex-1">{path}</span>
|
||||
<span className="font-mono text-muted-foreground shrink-0">
|
||||
{hash.slice(0, 8)}…{hash.slice(-4)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<NsiteIcon faviconUrl={faviconUrl} className="size-5" />
|
||||
<h2 className="text-lg font-semibold">{displayTitle}</h2>
|
||||
{identifier && <Label>{identifier}</Label>}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{legacy && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
This is a legacy nsite event (kind 34128). New sites should use kind
|
||||
15128 or 35128.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={gatewayUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-3.5" />
|
||||
View Site
|
||||
</a>
|
||||
</Button>
|
||||
{source && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href={source} target="_blank" rel="noopener noreferrer">
|
||||
<Code className="size-3.5" />
|
||||
Source
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
{paths.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="size-4" />
|
||||
<span>Files ({paths.length})</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{[...paths]
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.map(({ path, hash }) => (
|
||||
<NsiteFileRow
|
||||
key={path}
|
||||
path={path}
|
||||
hash={hash}
|
||||
serverUrl={servers[0]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Servers */}
|
||||
{servers.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<HardDrive className="size-4" />
|
||||
<span>Blossom Servers ({servers.length})</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{servers.map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center gap-2 py-0.5 group cursor-pointer hover:bg-muted/30 rounded px-1 -mx-1"
|
||||
onClick={() => handleServerClick(url)}
|
||||
>
|
||||
<HardDrive className="size-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-mono text-xs underline decoration-dotted flex-1 truncate">
|
||||
{url}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(url, "_blank");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relays */}
|
||||
{relays.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Radio className="size-4" />
|
||||
<span>Relays ({relays.length})</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{relays.map((url) => (
|
||||
<RelayLink key={url} url={url} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 15128 Detail Renderer - Root Nsite Manifest
|
||||
*/
|
||||
export function NsiteRootDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
return <NsiteDetailView event={event} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 35128 Detail Renderer - Named Nsite Manifest
|
||||
*/
|
||||
export function NsiteNamedDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
return <NsiteDetailView event={event} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 34128 Detail Renderer - Legacy Nsite (deprecated)
|
||||
*/
|
||||
export function NsiteLegacyDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
return <NsiteDetailView event={event} legacy />;
|
||||
}
|
||||
125
src/components/nostr/kinds/NsiteRenderer.tsx
Normal file
125
src/components/nostr/kinds/NsiteRenderer.tsx
Normal file
@@ -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 <Globe className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className={`${className} object-contain`}
|
||||
onError={() => 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 (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="flex items-center gap-1.5 text-sm font-medium"
|
||||
>
|
||||
<NsiteIcon faviconUrl={faviconUrl} className="size-4" />
|
||||
<span>{displayTitle}</span>
|
||||
{legacy && (
|
||||
<span className="text-xs text-muted-foreground font-normal">
|
||||
(legacy)
|
||||
</span>
|
||||
)}
|
||||
</ClickableEventTitle>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{paths.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="size-3.5" />
|
||||
<span>
|
||||
{paths.length} file{paths.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{servers.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="size-3.5" />
|
||||
<span>
|
||||
{servers.length} server{servers.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href={gatewayUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-auto flex items-center gap-1 hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span>Visit</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 15128 Renderer - Root Nsite Manifest (Feed View)
|
||||
*/
|
||||
export function NsiteRootRenderer({ event }: BaseEventProps) {
|
||||
return <NsiteRendererInner event={event} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 35128 Renderer - Named Nsite Manifest (Feed View)
|
||||
*/
|
||||
export function NsiteNamedRenderer({ event }: BaseEventProps) {
|
||||
return <NsiteRendererInner event={event} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 34128 Renderer - Legacy Nsite (Feed View, deprecated)
|
||||
*/
|
||||
export function NsiteLegacyRenderer({ event }: BaseEventProps) {
|
||||
return <NsiteRendererInner event={event} legacy />;
|
||||
}
|
||||
@@ -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<number, React.ComponentType<BaseEventProps>> = {
|
||||
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<number, React.ComponentType<BaseEventProps>> = {
|
||||
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)
|
||||
|
||||
226
src/hooks/useNsiteMetadata.ts
Normal file
226
src/hooks/useNsiteMetadata.ts
Normal file
@@ -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<string, NsiteMetadata>();
|
||||
|
||||
// Track in-flight fetches to avoid duplicate requests
|
||||
const pendingFetches = new Map<string, Promise<NsiteMetadata | null>>();
|
||||
|
||||
async function getCachedMetadata(hash: string): Promise<NsiteMetadata | null> {
|
||||
// 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<void> {
|
||||
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<NsiteMetadata | null> {
|
||||
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 <link rel="icon">, then <link rel="shortcut icon">
|
||||
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 <head> 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<NsiteMetadata | null>(() => {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
133
src/lib/nip5a-helpers.ts
Normal file
133
src/lib/nip5a-helpers.ts
Normal file
@@ -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://<npub>.nsite.lol
|
||||
* Named sites (35128): https://<pubkeyB36><dTag>.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}`;
|
||||
});
|
||||
}
|
||||
@@ -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<LocalSpell>;
|
||||
spellbooks!: Table<LocalSpellbook>;
|
||||
lnurlCache!: Table<LnurlCache>;
|
||||
nsiteMetadata!: Table<CachedNsiteMetadata>;
|
||||
grimoireZaps!: Table<GrimoireZap>;
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user