feat: kind schemas and better man pages

This commit is contained in:
Alejandro Gómez
2025-12-18 10:05:45 +01:00
parent 3b06e23686
commit a7dd4635dc
13 changed files with 2911 additions and 257 deletions

11
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"hls-video-element": "^1.5.10",
"hls.js": "^1.6.15",
"jotai": "^2.15.2",
"js-yaml": "^4.1.1",
"lucide-react": "latest",
"media-chrome": "^4.17.2",
"prismjs": "^1.30.0",
@@ -53,6 +54,7 @@
"devDependencies": {
"@eslint/js": "^9.17.0",
"@react-router/dev": "^7.1.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.7",
@@ -3817,6 +3819,13 @@
"@types/unist": "*"
}
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -4625,7 +4634,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
@@ -6312,7 +6320,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"

View File

@@ -42,6 +42,7 @@
"hls-video-element": "^1.5.10",
"hls.js": "^1.6.15",
"jotai": "^2.15.2",
"js-yaml": "^4.1.1",
"lucide-react": "latest",
"media-chrome": "^4.17.2",
"prismjs": "^1.30.0",
@@ -61,6 +62,7 @@
"devDependencies": {
"@eslint/js": "^9.17.0",
"@react-router/dev": "^7.1.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.7",

View File

@@ -23,6 +23,9 @@ import {
import { getEventDisplayTitle } from "@/lib/event-title";
import { UserName } from "./nostr/UserName";
import { getTagValues } from "@/lib/nostr-utils";
import { getLiveHost } from "@/lib/live-activity";
import type { NostrEvent } from "@/types/nostr";
import { getZapSender } from "applesauce-core/helpers/zap";
export interface WindowTitleData {
title: string | ReactElement;
@@ -30,6 +33,31 @@ export interface WindowTitleData {
tooltip?: string;
}
/**
* Get the semantic author of an event based on kind-specific logic
* Returns the pubkey that should be displayed as the "author" for UI purposes
*
* Examples:
* - Zaps (9735): Returns the zapper (P tag), not the lightning service pubkey
* - Live activities (30311): Returns the host (first p tag with "Host" role)
* - Regular events: Returns event.pubkey
*/
function getSemanticAuthor(event: NostrEvent): string {
switch (event.kind) {
case 9735: {
// Zap: show the zapper, not the lightning service pubkey
const zapSender = getZapSender(event);
return zapSender || event.pubkey;
}
case 30311: {
// Live activity: show the host
return getLiveHost(event);
}
default:
return event.pubkey;
}
}
/**
* Format profile names with prefix, handling $me and $contacts aliases
* @param prefix - Prefix to use (e.g., 'by ', '@ ')
@@ -266,6 +294,17 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
const eventPointer: EventPointer | AddressPointer | undefined =
appId === "open" ? props.pointer : undefined;
const event = useNostrEvent(eventPointer);
// Get semantic author for events (e.g., zapper for zaps, host for live activities)
const semanticAuthorPubkey = useMemo(() => {
if (appId !== "open" || !event) return null;
return getSemanticAuthor(event);
}, [appId, event]);
// Fetch semantic author profile to ensure it's cached for rendering
// Called for side effects (preloading profile data)
void useProfile(semanticAuthorPubkey || "");
const eventTitle = useMemo(() => {
if (appId !== "open" || !event) return null;
@@ -277,10 +316,13 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
</div>
{getEventDisplayTitle(event, false)}
<span> - </span>
<UserName pubkey={event.pubkey} className="text-inherit" />
<UserName
pubkey={semanticAuthorPubkey || event.pubkey}
className="text-inherit"
/>
</div>
);
}, [appId, event]);
}, [appId, event, semanticAuthorPubkey]);
// Kind titles
const kindTitle = useMemo(() => {
@@ -471,12 +513,12 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
return `NIP-${props.number}: ${title}`;
}, [appId, props]);
// Man page titles - just show the command description, icon shows on hover
// Man page titles - show command name first, then description
const manTitle = useMemo(() => {
if (appId !== "man") return null;
// For man pages, we'll show the command's description via tooltip
// The title can just be generic or empty, as the icon conveys meaning
return getCommandDescription(props.cmd) || `${props.cmd} manual`;
const cmdName = props.cmd?.toUpperCase() || "MAN";
const description = getCommandDescription(props.cmd);
return description ? `${cmdName} - ${description}` : cmdName;
}, [appId, props]);
// Kinds viewer title

View File

@@ -4,6 +4,11 @@ import { NIPBadge } from "./NIPBadge";
import { Copy, CopyCheck } from "lucide-react";
import { Button } from "./ui/button";
import { useCopy } from "@/hooks/useCopy";
import {
getKindSchema,
parseTagStructure,
getContentTypeDescription,
} from "@/lib/nostr-schema";
// NIP-01 Kind ranges
const REPLACEABLE_START = 10000;
@@ -15,6 +20,7 @@ const PARAMETERIZED_REPLACEABLE_END = 40000;
export default function KindRenderer({ kind }: { kind: number }) {
const kindInfo = getKindInfo(kind);
const schema = getKindSchema(kind);
const Icon = kindInfo?.icon;
const category = getKindCategory(kind);
const eventType = getEventType(kind);
@@ -94,6 +100,84 @@ export default function KindRenderer({ kind }: { kind: number }) {
</>
)}
</div>
{/* Schema Information */}
{schema && (
<>
{/* Content Type */}
{schema.content && (
<div>
<h2 className="text-lg font-semibold mb-2">Content</h2>
<p className="text-sm text-muted-foreground">
{getContentTypeDescription(schema.content.type)}
</p>
</div>
)}
{/* Tags */}
{schema.tags && schema.tags.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3">
Supported Tags
{schema.required && schema.required.length > 0 && (
<span className="text-sm font-normal text-muted-foreground ml-2">
({schema.required.length} required)
</span>
)}
</h2>
<div className="border border-border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-semibold w-20">name</th>
<th className="text-left p-3 font-semibold">value</th>
<th className="text-left p-3 font-semibold">other parameters</th>
</tr>
</thead>
<tbody>
{schema.tags.map((tag, i) => {
const isRequired = schema.required?.includes(tag.name);
const structure = parseTagStructure(tag);
return (
<tr
key={i}
className="border-t border-border hover:bg-muted/30"
>
<td className="p-3 align-top">
<code className="font-mono text-primary">
{tag.name}
</code>
{isRequired && (
<span className="ml-2 text-[10px] bg-destructive/20 text-destructive px-1.5 py-0.5 rounded whitespace-nowrap align-middle">
required
</span>
)}
</td>
<td className="p-3 text-muted-foreground align-top">
{structure.primaryValue || "—"}
</td>
<td className="p-3 text-muted-foreground align-top">
{structure.otherParameters.length > 0
? structure.otherParameters.join(", ")
: "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Usage Status */}
<div className="text-xs text-muted-foreground pt-2">
{schema.in_use
? "✓ Actively used in the Nostr ecosystem"
: "⚠ Deprecated or experimental"}
</div>
</>
)}
</div>
);
}

View File

@@ -1,9 +1,48 @@
import { manPages } from "@/types/man";
import { useGrimoire } from "@/core/state";
interface ManPageProps {
cmd: string;
}
/**
* ExecutableCommand - Renders a clickable command that executes when clicked
*/
function ExecutableCommand({
commandLine,
children,
}: {
commandLine: string;
children: React.ReactNode;
}) {
const { addWindow } = useGrimoire();
const handleClick = async () => {
const parts = commandLine.trim().split(/\s+/);
const commandName = parts[0]?.toLowerCase();
const cmdArgs = parts.slice(1);
const command = manPages[commandName];
if (command) {
// argParser can be async
const cmdProps = command.argParser
? await Promise.resolve(command.argParser(cmdArgs))
: command.defaultProps || {};
addWindow(command.appId, cmdProps);
}
};
return (
<button
onClick={handleClick}
className="text-accent font-medium hover:underline cursor-crosshair text-left"
>
{children}
</button>
);
}
export default function ManPage({ cmd }: ManPageProps) {
const page = manPages[cmd];
@@ -23,11 +62,11 @@ export default function ManPage({ cmd }: ManPageProps) {
{/* Header */}
<div className="flex justify-between border-b border-border pb-2">
<span className="font-bold">
{page.name.toUpperCase()}({page.section})
{page.name.toUpperCase()}
</span>
<span className="text-muted-foreground">Grimoire Manual</span>
<span className="font-bold">
{page.name.toUpperCase()}({page.section})
{page.name.toUpperCase()}
</span>
</div>
@@ -81,7 +120,9 @@ export default function ManPage({ cmd }: ManPageProps) {
const [, command, description] = match;
return (
<div key={i}>
<div className="text-accent font-medium">{command}</div>
<ExecutableCommand commandLine={command.trim()}>
{command}
</ExecutableCommand>
<div className="ml-8 text-muted-foreground text-sm">
{description.trim()}
</div>
@@ -90,8 +131,10 @@ export default function ManPage({ cmd }: ManPageProps) {
}
// Fallback for examples without descriptions
return (
<div key={i} className="text-accent font-medium">
{example}
<div key={i}>
<ExecutableCommand commandLine={example.trim()}>
{example}
</ExecutableCommand>
</div>
);
})}
@@ -104,14 +147,16 @@ export default function ManPage({ cmd }: ManPageProps) {
<section>
<h2 className="font-bold mb-2">SEE ALSO</h2>
<div className="ml-8">
<span className="text-accent">
{page.seeAlso.map((cmd, i) => (
<span key={i}>
{cmd}(1)
{i < page.seeAlso!.length - 1 ? ", " : ""}
</span>
))}
</span>
{page.seeAlso.map((cmd, i) => (
<span key={i}>
<ExecutableCommand commandLine={`man ${cmd}`}>
<span className="text-accent">{cmd}</span>
</ExecutableCommand>
{i < page.seeAlso!.length - 1 && (
<span className="text-accent">, </span>
)}
</span>
))}
</div>
</section>
)}

View File

@@ -1,22 +1,20 @@
import { useMemo } from "react";
import { useLiveTimeline } from "@/hooks/useLiveTimeline";
import type { NostrEvent } from "@/types/nostr";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { kinds } from "nostr-tools";
import { UserName } from "../nostr/UserName";
import { RichText } from "../nostr/RichText";
import { Zap } from "lucide-react";
import { UserName } from "./UserName";
import { RichText } from "./RichText";
import { Zap, CornerDownRight, Quote } from "lucide-react";
import { cn } from "@/lib/utils";
import { getZapAmount, getZapSender } from "applesauce-core/helpers";
import { getZapAmount, getZapSender, getTagValue } from "applesauce-core/helpers";
import { getNip10References } from "applesauce-core/helpers/threading";
import { useNostrEvent } from "@/hooks/useNostrEvent";
interface StreamChatProps {
streamEvent: NostrEvent;
streamRelays: string[];
hostRelays: string[];
interface ChatViewProps {
events: NostrEvent[];
className?: string;
}
// isConsecutive removed
const isSameDay = (date1: Date, date2: Date) => {
return (
date1.getFullYear() === date2.getFullYear() &&
@@ -25,63 +23,14 @@ const isSameDay = (date1: Date, date2: Date) => {
);
};
export function StreamChat({
streamEvent,
streamRelays,
hostRelays,
className,
}: StreamChatProps) {
// const [message, setMessage] = useState("");
// Combine stream relays + host relays
const allRelays = useMemo(
() => Array.from(new Set([...streamRelays, ...hostRelays])),
[streamRelays, hostRelays],
);
// Fetch chat messages (kind 1311) and zaps (kind 9735) that a-tag this stream
const timelineFilter = useMemo(
() => ({
kinds: [1311, 9735],
"#a": [
`${streamEvent.kind}:${streamEvent.pubkey}:${streamEvent.tags.find((t) => t[0] === "d")?.[1] || ""}`,
],
limit: 100,
}),
[streamEvent],
);
const { events: allMessages } = useLiveTimeline(
`stream-feed-${streamEvent.id}`,
timelineFilter,
allRelays,
{ stream: true },
);
/*
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// TODO: Implement sending chat message
console.log("Send message:", message);
setMessage("");
};
*/
export function ChatView({ events, className }: ChatViewProps) {
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Chat messages area */}
<div className="flex-1 flex flex-col-reverse gap-0.5 overflow-y-auto p-0 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{allMessages.map((event, index) => {
{events.map((event, index) => {
const currentDate = new Date(event.created_at * 1000);
const prevEvent = allMessages[index + 1];
// If prevEvent exists, compare days. If different, we need a separator AFTER this message (visually before/above it)
// Actually, in flex-col-reverse:
// [Newest Message] (index 0)
// <Day Label Today>
// [Old Message] (index 1)
// Wait, logic is simpler:
// Loop through events. Determine if Date Header is needed between this event and the next one (older one).
const prevEvent = events[index + 1];
const prevDate = prevEvent
? new Date(prevEvent.created_at * 1000)
@@ -138,17 +87,93 @@ export function StreamChat({
}
function ChatMessage({ event }: { event: NostrEvent }) {
const threadRefs = useMemo(() => getNip10References(event), [event]);
const replyToId = threadRefs.reply?.e?.id;
const qTagValue = useMemo(() => getTagValue(event, "q"), [event]);
return (
<RichText
className="text-xs leading-tight text-foreground/90"
event={event}
options={{ showMedia: false, showEventEmbeds: false }}
>
<div className="flex flex-col gap-0.5">
{replyToId && <ReplyIndicator eventId={replyToId} />}
{qTagValue && <QuoteIndicator qValue={qTagValue} />}
<RichText
className="text-xs leading-tight text-foreground/90"
event={event}
options={{ showMedia: false, showEventEmbeds: false }}
>
<UserName
pubkey={event.pubkey}
className="font-bold leading-tight flex-shrink-0 mr-1.5 text-accent"
/>
</RichText>
</div>
);
}
function ReplyIndicator({ eventId }: { eventId: string }) {
const replyToEvent = useNostrEvent(eventId);
if (!replyToEvent) {
return null;
}
return (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground pl-2 opacity-60">
<CornerDownRight className="w-3 h-3 flex-shrink-0" />
<UserName
pubkey={event.pubkey}
className="font-bold leading-tight flex-shrink-0 mr-1.5 text-accent"
pubkey={replyToEvent.pubkey}
className="font-semibold flex-shrink-0"
/>
</RichText>
<span className="truncate">{replyToEvent.content}</span>
</div>
);
}
/**
* Parse q-tag value into EventPointer or AddressPointer
* Format can be:
* - Event ID: "abc123..." (64-char hex)
* - Address: "kind:pubkey:d-tag"
*/
function parseQTag(qValue: string): EventPointer | AddressPointer | null {
// Check if it's an address (contains colons)
if (qValue.includes(":")) {
const parts = qValue.split(":");
if (parts.length >= 2) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const identifier = parts.slice(2).join(":") || "";
if (!isNaN(kind) && pubkey) {
return { kind, pubkey, identifier };
}
}
}
// Assume it's an event ID (hex string)
if (/^[0-9a-f]{64}$/i.test(qValue)) {
return { id: qValue };
}
return null;
}
function QuoteIndicator({ qValue }: { qValue: string }) {
const pointer = useMemo(() => parseQTag(qValue), [qValue]);
const quotedEvent = useNostrEvent(pointer || undefined);
if (!quotedEvent) {
return null;
}
return (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground pl-2 opacity-60">
<Quote className="w-3 h-3 flex-shrink-0" />
<UserName
pubkey={quotedEvent.pubkey}
className="font-semibold flex-shrink-0"
/>
<span className="truncate">{quotedEvent.content}</span>
</div>
);
}

View File

@@ -91,15 +91,15 @@ export function RichText({
// Call hook unconditionally - it will handle undefined/null
const trimmedEvent = event
? {
...event,
content: event.content.trim(),
}
...event,
content: event.content.trim(),
}
: undefined;
const renderedContent = useRenderedContent(
content
? ({
content,
} as NostrEvent)
content,
} as NostrEvent)
: trimmedEvent,
contentComponents,
);

View File

@@ -6,11 +6,12 @@ import {
getLiveHost,
} from "@/lib/live-activity";
import { VideoPlayer } from "@/components/live/VideoPlayer";
import { StreamChat } from "@/components/live/StreamChat";
import { ChatView } from "@/components/nostr/ChatView";
import { StatusBadge } from "@/components/live/StatusBadge";
import { UserName } from "../UserName";
import { Calendar } from "lucide-react";
import { useOutboxRelays } from "@/hooks/useOutboxRelays";
import { useLiveTimeline } from "@/hooks/useLiveTimeline";
interface LiveActivityDetailRendererProps {
event: NostrEvent;
@@ -28,6 +29,31 @@ export function LiveActivityDetailRenderer({
authors: [hostPubkey],
});
// Combine stream relays + host relays for chat events
const allRelays = useMemo(
() => Array.from(new Set([...activity.relays, ...hostRelays])),
[activity.relays, hostRelays],
);
// Fetch chat messages (kind 1311) and zaps (kind 9735) that a-tag this stream
const timelineFilter = useMemo(
() => ({
kinds: [1311, 9735],
"#a": [
`${event.kind}:${event.pubkey}:${event.tags.find((t) => t[0] === "d")?.[1] || ""}`,
],
limit: 100,
}),
[event],
);
const { events: chatEvents } = useLiveTimeline(
`stream-feed-${event.id}`,
timelineFilter,
allRelays,
{ stream: true },
);
const videoUrl =
status === "live" && activity.streaming
? activity.streaming
@@ -80,17 +106,15 @@ export function LiveActivityDetailRenderer({
<h1 className="text-lg font-bold flex-1 line-clamp-1">
{activity.title || "Untitled Live Activity"}
</h1>
<UserName pubkey={hostPubkey} className="text-sm font-semibold line-clamp-1" />
<UserName
pubkey={hostPubkey}
className="text-sm font-semibold line-clamp-1"
/>
</div>
{/* Chat Section */}
<div className="flex-1 min-h-0">
<StreamChat
streamEvent={event}
streamRelays={activity.relays}
hostRelays={hostRelays}
className="h-full"
/>
<ChatView events={chatEvents} className="h-full" />
</div>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -5,15 +5,15 @@ import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { isNostrEvent } from "@/lib/type-guards";
interface UseLiveTimelineOptions {
limit?: number;
stream?: boolean;
limit?: number;
stream?: boolean;
}
interface UseLiveTimelineReturn {
events: NostrEvent[];
loading: boolean;
error: Error | null;
eoseReceived: boolean;
events: NostrEvent[];
loading: boolean;
error: Error | null;
eoseReceived: boolean;
}
/**
@@ -27,103 +27,103 @@ interface UseLiveTimelineReturn {
* @returns Object containing events array (from store, sorted), loading state, and error
*/
export function useLiveTimeline(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseLiveTimelineOptions = { limit: 200 },
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseLiveTimelineOptions = { limit: 1000 },
): UseLiveTimelineReturn {
const eventStore = useEventStore();
const { limit, stream = false } = options;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [eoseReceived, setEoseReceived] = useState(false);
const eventStore = useEventStore();
const { limit, stream = false } = options;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [eoseReceived, setEoseReceived] = useState(false);
// Stabilize filters and relays for dependency array
// Using JSON.stringify and .join() for deep comparison - this is intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableRelays = useMemo(() => relays, [relays.join(",")]);
// Stabilize filters and relays for dependency array
// Using JSON.stringify and .join() for deep comparison - this is intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableRelays = useMemo(() => relays, [relays.join(",")]);
// 1. Subscription Effect - Fetch data and feed EventStore
useEffect(() => {
if (relays.length === 0) {
// 1. Subscription Effect - Fetch data and feed EventStore
useEffect(() => {
if (relays.length === 0) {
setLoading(false);
return;
}
console.log("LiveTimeline: Starting query", {
id,
relays,
filters,
limit,
stream,
});
setLoading(true);
setError(null);
setEoseReceived(false);
// Normalize filters to array
const filterArray = Array.isArray(filters) ? filters : [filters];
// Add limit to filters if specified
const filtersWithLimit = filterArray.map((f) => ({
...f,
limit: limit || f.limit,
}));
const observable = pool.subscription(relays, filtersWithLimit, {
retries: 5,
reconnect: 5,
resubscribe: true,
eventStore, // Automatically add events to store
});
const subscription = observable.subscribe(
(response) => {
// Response can be an event or 'EOSE' string
if (typeof response === "string") {
console.log("LiveTimeline: EOSE received");
setEoseReceived(true);
if (!stream) {
setLoading(false);
return;
}
} else if (isNostrEvent(response)) {
// Event automatically added to store by pool.subscription (via options.eventStore)
} else {
console.warn("LiveTimeline: Unexpected response type:", response);
}
},
(err: Error) => {
console.error("LiveTimeline: Error", err);
setError(err);
setLoading(false);
},
() => {
// Only set loading to false if not streaming
if (!stream) {
setLoading(false);
}
},
);
console.log("LiveTimeline: Starting query", {
id,
relays,
filters,
limit,
stream,
});
setLoading(true);
setError(null);
setEoseReceived(false);
// Normalize filters to array
const filterArray = Array.isArray(filters) ? filters : [filters];
// Add limit to filters if specified
const filtersWithLimit = filterArray.map((f) => ({
...f,
limit: limit || f.limit,
}));
const observable = pool.subscription(relays, filtersWithLimit, {
retries: 5,
reconnect: 5,
resubscribe: true,
eventStore, // Automatically add events to store
});
const subscription = observable.subscribe(
(response) => {
// Response can be an event or 'EOSE' string
if (typeof response === "string") {
console.log("LiveTimeline: EOSE received");
setEoseReceived(true);
if (!stream) {
setLoading(false);
}
} else if (isNostrEvent(response)) {
// Event automatically added to store by pool.subscription (via options.eventStore)
} else {
console.warn("LiveTimeline: Unexpected response type:", response);
}
},
(err: Error) => {
console.error("LiveTimeline: Error", err);
setError(err);
setLoading(false);
},
() => {
// Only set loading to false if not streaming
if (!stream) {
setLoading(false);
}
},
);
return () => {
subscription.unsubscribe();
};
}, [id, stableFilters, stableRelays, limit, stream, eventStore]);
// 2. Observable Effect - Read from EventStore
const timelineEvents = useObservableMemo(() => {
// eventStore.timeline returns an Observable that emits sorted array of events matching filter
// It updates whenever relevant events are added/removed from store
return eventStore.timeline(filters);
}, [stableFilters]);
return {
events: timelineEvents || [],
loading,
error,
eoseReceived,
return () => {
subscription.unsubscribe();
};
}, [id, stableFilters, stableRelays, limit, stream, eventStore]);
// 2. Observable Effect - Read from EventStore
const timelineEvents = useObservableMemo(() => {
// eventStore.timeline returns an Observable that emits sorted array of events matching filter
// It updates whenever relevant events are added/removed from store
return eventStore.timeline(filters);
}, [stableFilters]);
return {
events: timelineEvents || [],
loading,
error,
eoseReceived,
};
}

View File

@@ -0,0 +1,219 @@
import { describe, it, expect } from "vitest";
import {
loadSchema,
getKindSchema,
getAllKinds,
formatTag,
parseTagStructure,
getContentTypeDescription,
} from "./nostr-schema";
describe("nostr-schema", () => {
describe("loadSchema", () => {
it("should load and parse the schema", () => {
const schema = loadSchema();
expect(schema).toBeDefined();
expect(Object.keys(schema).length).toBeGreaterThan(0);
});
it("should parse kind numbers correctly", () => {
const schema = loadSchema();
expect(schema[0]).toBeDefined(); // Metadata
expect(schema[1]).toBeDefined(); // Note
expect(schema[3]).toBeDefined(); // Contacts
});
});
describe("getKindSchema", () => {
it("should get schema for kind 1", () => {
const schema = getKindSchema(1);
expect(schema).toBeDefined();
expect(schema?.description).toBe("Short text note");
expect(schema?.in_use).toBe(true);
expect(schema?.content?.type).toBe("free");
});
it("should return undefined for unknown kind", () => {
const schema = getKindSchema(999999);
expect(schema).toBeUndefined();
});
it("should have tags for kind 1", () => {
const schema = getKindSchema(1);
expect(schema?.tags).toBeDefined();
expect(Array.isArray(schema?.tags)).toBe(true);
expect(schema?.tags && schema.tags.length > 0).toBe(true);
});
});
describe("getAllKinds", () => {
it("should return sorted array of kind numbers", () => {
const kinds = getAllKinds();
expect(Array.isArray(kinds)).toBe(true);
expect(kinds.length).toBeGreaterThan(0);
// Check if sorted
for (let i = 1; i < kinds.length; i++) {
expect(kinds[i]).toBeGreaterThan(kinds[i - 1]);
}
});
});
describe("formatTag", () => {
it("should format simple tag", () => {
const result = formatTag({
name: "e",
next: {
type: "id",
required: true,
},
});
expect(result).toBe("#e <id>");
});
it("should format tag with multiple values", () => {
const result = formatTag({
name: "p",
next: {
type: "pubkey",
required: true,
next: {
type: "relay",
},
},
});
expect(result).toBe("#p <pubkey> <relay>");
});
it("should indicate variadic tags", () => {
const result = formatTag({
name: "t",
variadic: true,
next: {
type: "free",
required: true,
},
});
expect(result).toBe("#t <text> (multiple)");
});
it("should format constrained types", () => {
const result = formatTag({
name: "status",
next: {
type: "constrained",
either: ["accepted", "declined"],
},
});
expect(result).toBe("#status <accepted|declined>");
});
it("should convert 'free' type to 'text'", () => {
const result = formatTag({
name: "subject",
next: {
type: "free",
required: true,
},
});
expect(result).toBe("#subject <text>");
});
});
describe("parseTagStructure", () => {
it("should parse single value tag", () => {
const result = parseTagStructure({
name: "e",
next: {
type: "id",
required: true,
},
});
expect(result.primaryValue).toBe("id");
expect(result.otherParameters).toEqual([]);
});
it("should parse tag with multiple parameters", () => {
const result = parseTagStructure({
name: "p",
next: {
type: "pubkey",
required: true,
next: {
type: "relay",
next: {
type: "free",
},
},
},
});
expect(result.primaryValue).toBe("pubkey");
expect(result.otherParameters).toEqual([
"relay (e.g. wss://grimoire.rocks)",
"text",
]);
});
it("should parse tag with constrained values", () => {
const result = parseTagStructure({
name: "status",
next: {
type: "constrained",
either: ["accepted", "declined", "tentative"],
},
});
expect(result.primaryValue).toBe("accepted | declined | tentative");
expect(result.otherParameters).toEqual([]);
});
it("should handle tag with no parameters", () => {
const result = parseTagStructure({
name: "t",
});
expect(result.primaryValue).toBe("");
expect(result.otherParameters).toEqual([]);
});
it("should show grimoire.rocks example for url parameters", () => {
const result = parseTagStructure({
name: "r",
next: {
type: "url",
required: true,
},
});
expect(result.primaryValue).toBe("url (e.g. https://grimoire.rocks)");
expect(result.otherParameters).toEqual([]);
});
it("should show grimoire.rocks example for relay parameters", () => {
const result = parseTagStructure({
name: "relay",
next: {
type: "relay",
required: true,
},
});
expect(result.primaryValue).toBe("relay (e.g. wss://grimoire.rocks)");
expect(result.otherParameters).toEqual([]);
});
});
describe("getContentTypeDescription", () => {
it("should describe free content", () => {
expect(getContentTypeDescription("free")).toBe(
"Free-form text or markdown"
);
});
it("should describe json content", () => {
expect(getContentTypeDescription("json")).toBe("JSON object");
});
it("should describe empty content", () => {
expect(getContentTypeDescription("empty")).toBe(
"Empty (no content field)"
);
});
});
});

154
src/lib/nostr-schema.ts Normal file
View File

@@ -0,0 +1,154 @@
import yaml from "js-yaml";
import schemaYaml from "@/data/nostr-kinds-schema.yaml?raw";
/**
* Nostr event schema types based on the official registry
*/
export interface TagDefinition {
name: string;
next?: TagValueDefinition;
variadic?: boolean;
}
export interface TagValueDefinition {
type: string;
required?: boolean;
next?: TagValueDefinition;
either?: string[]; // For constrained types
variadic?: boolean;
}
export interface KindSchema {
description: string;
in_use: boolean;
content?: {
type: "free" | "json" | "empty";
};
tags?: TagDefinition[];
required?: string[]; // List of required tag names
}
export type NostrSchema = Record<number, KindSchema>;
let parsedSchema: NostrSchema | null = null;
/**
* Parse the YAML schema
*/
export function loadSchema(): NostrSchema {
if (parsedSchema) return parsedSchema;
try {
const data = yaml.load(schemaYaml) as any;
parsedSchema = {};
// The kinds are nested under a "kinds" key
const kindsData = data.kinds || data;
// Extract kind definitions (filter out anchor definitions starting with _)
for (const [key, value] of Object.entries(kindsData)) {
if (!key.startsWith("_") && !isNaN(Number(key))) {
parsedSchema[Number(key)] = value as KindSchema;
}
}
return parsedSchema;
} catch (error) {
console.error("Failed to parse Nostr schema:", error);
return {};
}
}
/**
* Get schema for a specific kind
*/
export function getKindSchema(kind: number): KindSchema | undefined {
const schema = loadSchema();
return schema[kind];
}
/**
* Get all available kinds from schema
*/
export function getAllKinds(): number[] {
const schema = loadSchema();
return Object.keys(schema)
.map(Number)
.sort((a, b) => a - b);
}
/**
* Format tag definition as a readable string
*/
export function formatTag(tag: TagDefinition): string {
let result = `#${tag.name}`;
let current = tag.next;
const parts: string[] = [];
while (current) {
if (current.either) {
parts.push(`<${current.either.join("|")}>`);
} else {
// Replace 'free' with 'text' for better readability
const type = current.type === "free" ? "text" : current.type;
parts.push(`<${type}>`);
}
current = current.next;
}
if (parts.length > 0) {
result += ` ${parts.join(" ")}`;
}
if (tag.variadic) {
result += " (multiple)";
}
return result;
}
/**
* Parse tag structure into primary value and other parameters
*/
export function parseTagStructure(tag: TagDefinition): {
primaryValue: string;
otherParameters: string[];
} {
const parts: string[] = [];
let current = tag.next;
while (current) {
if (current.either) {
parts.push(`${current.either.join(" | ")}`);
} else {
// Replace 'free' with 'text' for better readability
const type = current.type === "free" ? "text" : current.type;
parts.push(type);
}
current = current.next;
}
return {
primaryValue: parts[0] || "",
otherParameters: parts.slice(1),
};
}
/**
* Get content type description
*/
export function getContentTypeDescription(
contentType: "free" | "json" | "empty"
): string {
switch (contentType) {
case "free":
return "Free-form text or markdown";
case "json":
return "JSON object";
case "empty":
return "Empty (no content field)";
default:
return "Unknown";
}
}

View File

@@ -39,7 +39,7 @@ export const manPages: Record<string, ManPageEntry> = {
"nip 19 View the bech32 encoding specification",
"nip b0 View the NIP-B0 specification",
],
seeAlso: ["feed", "kind"],
seeAlso: ["nips", "kind", "kinds"],
appId: "nip",
category: "Documentation",
argParser: (args: string[]) => {
@@ -67,7 +67,7 @@ export const manPages: Record<string, ManPageEntry> = {
"kind 0 View metadata event kind",
"kind 1 View short text note kind",
],
seeAlso: ["nip"],
seeAlso: ["kinds", "nip", "nips"],
appId: "kind",
category: "Documentation",
argParser: (args: string[]) => ({ number: args[0] || "1" }),
@@ -79,10 +79,6 @@ export const manPages: Record<string, ManPageEntry> = {
synopsis: "help",
description:
"Display general help information about Grimoire and available commands.",
examples: [
"Use man <command> to view detailed documentation",
"Click any command to open its man page",
],
seeAlso: ["man", "nip", "kind"],
appId: "man",
category: "System",
@@ -137,7 +133,7 @@ export const manPages: Record<string, ManPageEntry> = {
},
],
examples: [
"man feed View the feed command manual",
"man req View the req command manual",
"man nip View the nip command manual",
],
seeAlso: ["help"],
@@ -223,33 +219,32 @@ export const manPages: Record<string, ManPageEntry> = {
},
],
examples: [
"req -k 1 -l 20 Get 20 recent notes (auto-selects optimal relays via NIP-65)",
"req -k 1,3,7 -l 50 Get notes, contact lists, and reactions",
"req -k 0 -a npub1... Get profile (queries author's outbox relays)",
"req -k 1 -a user@domain.com Get notes from NIP-05 identifier",
"req -k 1 -a dergigi.com Get notes from bare domain (resolves to _@dergigi.com)",
"req -k 1 -a npub1...,npub2... Get notes from multiple authors (balances across outbox relays)",
"req -a $me Get all your events (queries your outbox relays)",
"req -k 1 -a $contacts --since 24h Get notes from contacts (queries their outbox relays)",
"req -k 1 -a $contacts --since 7d Get notes from contacts in last week",
"req -k 1 -a $contacts --since 3mo Get notes from contacts in last 3 months",
"req -k 1 -a $contacts --since 1y Get notes from contacts in last year",
"req -p $me -k 1,7 Get replies and reactions to you (queries your inbox relays)",
"req -k 1 -a $me -a $contacts Get notes from you and contacts",
"req -k 9735 -p $me --since 7d Get zaps you received (queries your inbox)",
"req -k 9735 -P $me --since 7d Get zaps you sent",
"req -k 9735 -P $contacts Get zaps sent by your contacts",
"req -k 1 -p verbiricha@habla.news Get notes mentioning user (queries their inbox)",
"req -k 1 --since 1h relay.damus.io Get notes from last hour (manual relay override)",
"req -k 1 --since 7d --until now Get notes from last week up to now",
"req -k 1 --close-on-eose Get recent notes and close after EOSE",
"req -t nostr,bitcoin -l 50 Get 50 events tagged #nostr or #bitcoin",
"req --tag a 30023:abc...:article Get events referencing addressable event (#a tag)",
"req -T r https://example.com Get events referencing URL (#r tag)",
"req -k 30023 --tag d article1,article2 Get specific replaceable events by d-tag",
"req --tag g geohash123 -l 20 Get 20 events with geolocation tag",
"req --search bitcoin -k 1 Search notes for 'bitcoin'",
"req -k 1 relay1.com relay2.com Query specific relays (overrides auto-selection)",
"req -k 1 -l 20 Get 20 recent notes (auto-selects optimal relays via NIP-65)",
"req -k 1,3,7 -l 50 Get notes, contact lists, and reactions",
"req -k 0 -a fiatjaf.com Get profile (queries author's outbox relays)",
"req -k 1 -a verbiricha@habla.news Get notes from NIP-05 identifier",
"req -k 1 -a dergigi.com Get notes from bare domain (resolves to _@dergigi.com)",
"req -k 1 -a fiatjaf.com,dergigi.com Get notes from multiple authors (balances across outbox relays)",
"req -a $me Get all your events (queries your outbox relays)",
"req -k 1 -a $contacts --since 24h Get notes from contacts (queries their outbox relays)",
"req -k 1 -a $contacts --since 7d Get notes from contacts in last week",
"req -k 1 -a $contacts --since 3mo Get notes from contacts in last 3 months",
"req -k 1 -a $contacts --since 1y Get notes from contacts in last year",
"req -p $me -k 1,7 Get replies and reactions to you (queries your inbox relays)",
"req -k 1 -a $me -a $contacts Get notes from you and contacts",
"req -k 9735 -p $me --since 7d Get zaps you received (queries your inbox)",
"req -k 9735 -P $me --since 7d Get zaps you sent",
"req -k 9735 -P $contacts Get zaps sent by your contacts",
"req -k 1 -p verbiricha@habla.news Get notes mentioning user (queries their inbox)",
"req -k 1 --since 1h relay.damus.io Get notes from last hour (manual relay override)",
"req -k 1 --since 7d --until now Get notes from last week up to now",
"req -k 1 --close-on-eose Get recent notes and close after EOSE",
"req -t nostr,grimoire,bitcoin -l 50 Get 50 events tagged #nostr, #grimoire, or #bitcoin",
"req --tag a 30023:7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194:grimoire Get events referencing addressable event (#a tag)",
"req -T r grimoire.rocks Get events referencing URL (#r tag)",
"req -k 30023 --tag d badges,grimoire Get specific replaceable events by d-tag",
"req --search bitcoin -k 1 Search notes for 'bitcoin'",
"req -k 1 theforest.nostr1.com relay.damus.io Query specific relays (overrides auto-selection)",
],
seeAlso: ["kind", "nip"],
appId: "req",
@@ -323,11 +318,9 @@ export const manPages: Record<string, ManPageEntry> = {
},
],
examples: [
"open note1abc... Open event by note1 ID",
"open nevent1xyz... Open event with relay hints",
"open naddr1def... Open addressable event",
"open abc123... Open event by hex ID (64 chars)",
"open 30023:abc123...:my-article Open by address pointer (kind:pubkey:d-tag)",
"open nevent1qgs8lft0t45k92c78n2zfe6ccvqzhpn977cd3h8wnl579zxhw5dvr9qqyz4nf2hlglhzhezygl5x2fdsg332fyd9q0p8ja7kvn0g53e0edzyxa32zg8 Open event with relay hints",
"open naddr1qvzqqqrkvupzpn6956apxcad0mfp8grcuugdysg44eepex68h50t73zcathmfs49qy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq3wamnwvaz7tmjv4kxz7fwwpexjmtpdshxuet59uq3qamnwvaz7tmwdaehgu3wd4hk6tcpz9mhxue69uhkummnw3ezuamfdejj7qghwaehxw309a3xjarrda5kuetj9eek7cmfv9kz7qg4waehxw309aex2mrp0yhxgctdw4eju6t09uq3samnwvaz7tmxd9k8getj9ehx7um5wgh8w6twv5hszymhwden5te0danxvcmgv95kutnsw43z7qgawaehxw309ahx7um5wghxy6t5vdhkjmn9wgh8xmmrd9skctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uqsuamnwvaz7tmev9382tndv5hsz9nhwden5te0wfjkccte9e3k76twdaeju6t09uq3vamnwvaz7tmjv4kxz7fwxvuns6np9eu8j730qqjr2vehvyenvdtr94nrzetr956rgctr94skvvfs95eryep3x3snwve389nxy97cjwx Open addressable event",
"open 30023:7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194:grimoire Open by address pointer (kind:pubkey:d-tag)",
],
seeAlso: ["req", "kind"],
appId: "open",
@@ -350,12 +343,11 @@ export const manPages: Record<string, ManPageEntry> = {
},
],
examples: [
"profile npub1abc... Open profile by npub",
"profile nprofile1xyz... Open profile with relay hints",
"profile abc123... Open profile by hex pubkey (64 chars)",
"profile user@domain.com Open profile by NIP-05 identifier",
"profile jack@cash.app Open profile using NIP-05",
"profile fiatjaf.com Open profile by NIP-05 identifier",
"profile nprofile1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309a6xsetxdaex2um59ehx7um5wgcjucm0d5hsz9mhwden5te0veex2mnn9ehx7um5wgcjucm0d5hszxrhwden5te0ve5kcar9wghxummnw3ezuamfdejj7qpq07jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2q0al9p4 Open profile with relay hints",
"profile 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d Open profile by hex pubkey (64 chars)",
"profile dergigi.com Open profile by domain (resolves to _@dergigi.com)",
"profile jack@cash.app Open profile using NIP-05",
],
seeAlso: ["open", "req"],
appId: "profile",
@@ -391,11 +383,11 @@ export const manPages: Record<string, ManPageEntry> = {
},
],
examples: [
"encode npub abc123... Encode pubkey to npub",
"encode nprofile abc123... --relay wss://relay.example.com",
"encode note def456... Encode event ID to note",
"encode nevent def456... --relay wss://relay.example.com --author abc123...",
"encode naddr 30023:abc123...:article --relay wss://relay.example.com",
"encode npub 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d Encode pubkey to npub",
"encode nprofile 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d --relay wss://theforest.nostr1.com Encode profile with relay",
"encode note 5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36 Encode event ID to note",
"encode nevent 5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36 --relay wss://theforest.nostr1.com --author 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d Encode event with metadata",
"encode naddr 30023:3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d:my-article --relay wss://theforest.nostr1.com Encode addressable event",
],
seeAlso: ["decode"],
appId: "encode",
@@ -417,10 +409,9 @@ export const manPages: Record<string, ManPageEntry> = {
},
],
examples: [
"decode npub1abc... Decode npub to hex pubkey",
"decode nevent1xyz... Decode nevent showing ID, relays, author",
"decode naddr1def... Decode naddr showing kind, pubkey, identifier",
"decode nprofile1ghi... Decode nprofile with relay hints",
"decode nprofile1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309a6xsetxdaex2um59ehx7um5wgcjucm0d5hsz9mhwden5te0veex2mnn9ehx7um5wgcjucm0d5hszxrhwden5te0ve5kcar9wghxummnw3ezuamfdejj7qpq07jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2q0al9p4 Decode nprofile with relay hints",
"decode nevent1qgs8lft0t45k92c78n2zfe6ccvqzhpn977cd3h8wnl579zxhw5dvr9qqyz4nf2hlglhzhezygl5x2fdsg332fyd9q0p8ja7kvn0g53e0edzyxa32zg8 Decode nevent showing ID, relays, author",
"decode naddr1qvzqqqrkvupzpn6956apxcad0mfp8grcuugdysg44eepex68h50t73zcathmfs49qy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq3wamnwvaz7tmjv4kxz7fwwpexjmtpdshxuet59uq3qamnwvaz7tmwdaehgu3wd4hk6tcpz9mhxue69uhkummnw3ezuamfdejj7qghwaehxw309a3xjarrda5kuetj9eek7cmfv9kz7qg4waehxw309aex2mrp0yhxgctdw4eju6t09uq3samnwvaz7tmxd9k8getj9ehx7um5wgh8w6twv5hszymhwden5te0danxvcmgv95kutnsw43z7qgawaehxw309ahx7um5wghxy6t5vdhkjmn9wgh8xmmrd9skctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uqsuamnwvaz7tmev9382tndv5hsz9nhwden5te0wfjkccte9e3k76twdaeju6t09uq3vamnwvaz7tmjv4kxz7fwxvuns6np9eu8j730qqjr2vehvyenvdtr94nrzetr956rgctr94skvvfs95eryep3x3snwve389nxy97cjwx Decode naddr showing kind, pubkey, identifier",
],
seeAlso: ["encode"],
appId: "decode",
@@ -444,7 +435,6 @@ export const manPages: Record<string, ManPageEntry> = {
],
examples: [
"relay wss://relay.damus.io View relay information",
"relay relay.primal.net Auto-adds wss:// protocol",
"relay nos.lol View relay capabilities",
],
seeAlso: ["req", "profile"],