diff --git a/package-lock.json b/package-lock.json index 056419c..7650326 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 571e01a..119687f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 2828634..8100f1f 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -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 { {getEventDisplayTitle(event, false)} - - + ); - }, [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 diff --git a/src/components/KindRenderer.tsx b/src/components/KindRenderer.tsx index 46c175e..563417e 100644 --- a/src/components/KindRenderer.tsx +++ b/src/components/KindRenderer.tsx @@ -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 }) { )} + + {/* Schema Information */} + {schema && ( + <> + {/* Content Type */} + {schema.content && ( +
+

Content

+

+ {getContentTypeDescription(schema.content.type)} +

+
+ )} + + {/* Tags */} + {schema.tags && schema.tags.length > 0 && ( +
+

+ Supported Tags + {schema.required && schema.required.length > 0 && ( + + ({schema.required.length} required) + + )} +

+
+ + + + + + + + + + {schema.tags.map((tag, i) => { + const isRequired = schema.required?.includes(tag.name); + const structure = parseTagStructure(tag); + return ( + + + + + + ); + })} + +
namevalueother parameters
+ + {tag.name} + + {isRequired && ( + + required + + )} + + {structure.primaryValue || "—"} + + {structure.otherParameters.length > 0 + ? structure.otherParameters.join(", ") + : "—"} +
+
+
+ )} + + {/* Usage Status */} +
+ {schema.in_use + ? "✓ Actively used in the Nostr ecosystem" + : "⚠ Deprecated or experimental"} +
+ + )} ); } diff --git a/src/components/ManPage.tsx b/src/components/ManPage.tsx index 6f46abc..2c0945d 100644 --- a/src/components/ManPage.tsx +++ b/src/components/ManPage.tsx @@ -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 ( + + ); +} + export default function ManPage({ cmd }: ManPageProps) { const page = manPages[cmd]; @@ -23,11 +62,11 @@ export default function ManPage({ cmd }: ManPageProps) { {/* Header */}
- {page.name.toUpperCase()}({page.section}) + {page.name.toUpperCase()} Grimoire Manual - {page.name.toUpperCase()}({page.section}) + {page.name.toUpperCase()}
@@ -81,7 +120,9 @@ export default function ManPage({ cmd }: ManPageProps) { const [, command, description] = match; return (
-
{command}
+ + {command} +
{description.trim()}
@@ -90,8 +131,10 @@ export default function ManPage({ cmd }: ManPageProps) { } // Fallback for examples without descriptions return ( -
- {example} +
+ + {example} +
); })} @@ -104,14 +147,16 @@ export default function ManPage({ cmd }: ManPageProps) {

SEE ALSO

- - {page.seeAlso.map((cmd, i) => ( - - {cmd}(1) - {i < page.seeAlso!.length - 1 ? ", " : ""} - - ))} - + {page.seeAlso.map((cmd, i) => ( + + + {cmd} + + {i < page.seeAlso!.length - 1 && ( + , + )} + + ))}
)} diff --git a/src/components/live/StreamChat.tsx b/src/components/nostr/ChatView.tsx similarity index 51% rename from src/components/live/StreamChat.tsx rename to src/components/nostr/ChatView.tsx index 0805c5f..717621d 100644 --- a/src/components/live/StreamChat.tsx +++ b/src/components/nostr/ChatView.tsx @@ -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 (
{/* Chat messages area */}
- {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) - // - // [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 ( - +
+ {replyToId && } + {qTagValue && } + + + +
+ ); +} + +function ReplyIndicator({ eventId }: { eventId: string }) { + const replyToEvent = useNostrEvent(eventId); + + if (!replyToEvent) { + return null; + } + + return ( +
+ - + {replyToEvent.content} +
+ ); +} + +/** + * 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 ( +
+ + + {quotedEvent.content} +
); } diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx index 0c92085..1387729 100644 --- a/src/components/nostr/RichText.tsx +++ b/src/components/nostr/RichText.tsx @@ -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, ); diff --git a/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx index 187d373..bb3003c 100644 --- a/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx +++ b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx @@ -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({

{activity.title || "Untitled Live Activity"}

- +
{/* Chat Section */}
- +
); diff --git a/src/data/nostr-kinds-schema.yaml b/src/data/nostr-kinds-schema.yaml new file mode 100644 index 0000000..7f172c2 --- /dev/null +++ b/src/data/nostr-kinds-schema.yaml @@ -0,0 +1,2062 @@ +_profile: &profile + type: pubkey + required: true + next: + type: relay + next: + type: free # petname + +_external: &external + type: free + required: true + next: + type: url + +_event: &event + type: id + required: true + next: + type: relay + next: + type: pubkey + +_addr: &addr + type: addr + required: true + next: + type: relay + +_kind: &kind + type: kind + required: true + +_rtag: &rtag + name: r + next: + type: url + required: true + +_relaytag: &relaytag + name: relay + next: + type: relay + required: true + +_imetatag: &imetatag + name: imeta + next: + type: imeta + required: true + variadic: true + +_emojitag: &emojitag + name: emoji + next: + type: free + required: true + next: + type: url + required: true + +_title: &titletag + name: title + next: + type: free + +_image: &imagetag + name: image + next: + type: url + required: true + +_description: &descriptiontag + name: description + next: + type: free + required: true + +_summary: &summarytag + name: summary + next: + type: free + required: true + +_publishedattag: &publishedattag + name: published_at + next: + type: timestamp + required: true + +_atag: &atag + name: a + next: *addr + +_ptag: &ptag + name: p + next: *profile + +_etag: &etag + name: e + next: *event + +_ktag: &ktag + name: k + next: *kind + +_gtag: >ag + name: g + next: + type: geohash + required: true + +_ptag-bare: &ptag-bare + name: p + next: + type: pubkey + required: true + +_etag-bare: &etag-bare + name: e + next: + type: id + required: true + +_atag-bare: &atag-bare + name: a + next: + type: addr + required: true + +generic_tags: + t: + type: lowercase + required: true + L: + type: free + required: true + l: + type: lowercase + required: true + next: + type: free + required: true + expiration: + type: timestamp + required: true + h: + type: free + required: true + +kinds: + 0: + description: User metadata + in_use: true + content: + type: json + tags: + - *emojitag + + 1: + description: Short text note + in_use: true + content: + type: free + tags: + - + name: e + next: + type: id + required: true + next: + type: relay + next: + type: constrained + either: + - reply + - root + next: + type: pubkey + - + name: q + next: + type: id + required: true + next: + type: relay + next: + type: pubkey + - + name: q + next: + type: addr + required: true + next: + type: relay + - + name: p + next: *profile + - + name: a + next: *addr + - + name: subject + next: + type: free + required: true + - *emojitag + + 3: + description: Follows + in_use: true + content: + type: free + tags: + - *ptag + + 4: + description: Encrypted Direct Messages + content: + type: free + tags: + - *ptag + + 5: + description: Event Deletion Request + in_use: true + content: + type: empty + tags: + - *etag-bare + - *atag-bare + + 6: + description: Repost + in_use: true + content: + type: json + tags: + - *etag + - *ptag + + 7: + description: Reaction + in_use: true + content: + type: free + tags: + - *etag + - *ptag + - *emojitag + + 8: + description: Badge Award + in_use: true + content: + type: empty + tags: + - *atag + - *ptag + + 9: + description: Chat Message + in_use: true + content: + type: free + tags: + - + name: e + next: + type: id + required: true + next: + type: relay + next: + type: constrained + either: + - root + next: + type: pubkey + - + name: q + next: + type: id + required: true + next: + type: relay + next: + type: pubkey + - + name: q + next: + type: addr + required: true + next: + type: relay + - *ptag + + 11: + description: Forum Thread + in_use: true + content: + type: free + tags: + - *titletag + + 13: + description: Seal + in_use: true + content: + type: free + tags: [] + + 14: + description: Direct Message + in_use: true + content: + type: free + tags: + - *ptag + - *etag + - + name: subject + next: + type: free + required: true + + 15: + description: File Message + in_use: true + content: + type: free + tags: + - *ptag + - + name: e + next: + type: id + required: true + next: + type: relay + next: + type: constrained + either: + - reply + - + name: subject + next: + type: free + required: true + - + name: file-type + next: + type: free + required: true + - + name: encryption-algorithm + next: + type: free + required: true + - + name: decryption-key + next: + type: free + required: true + - + name: decryption-nonce + next: + type: free + required: true + - + name: x + next: + type: free + required: true + + 16: + description: Generic Repost + in_use: true + content: + type: json + tags: + - *etag + - *ptag + - *ktag + + 17: + description: Reaction to a website + content: + type: free + required: + - r + tags: + - *rtag + + 20: + description: Photo + in_use: true + content: + type: free + required: + - imeta + tags: + - *titletag + - *imetatag + - + name: content-warning + next: + type: free + required: true + - *ptag + - + name: m + next: + type: constrained + either: + - image/apng + - image/avif + - image/gif + - image/jpeg + - image/png + - image/webp + required: true + - + name: x + next: + type: hex + min: 64 + max: 64 + required: true + - + name: location + next: + type: free + required: true + - *gtag + + 21: + description: Normal Video Event + in_use: true + content: + type: free + required: + - imeta + tags: + - *titletag + - *publishedattag + - *imetatag + - + name: content-warning + next: + type: free + required: true + - + name: segment + next: + type: free + required: true + next: + type: free + required: true + next: + type: free + required: true + next: + type: url + - *ptag + - *rtag + - + name: text-track + next: + type: json + required: true + next: + type: relay + + 22: + description: Short Video Event + in_use: true + content: + type: free + required: + - imeta + tags: + - *titletag + - *publishedattag + - *imetatag + - + name: content-warning + next: + type: free + required: true + - + name: segment + next: + type: free + required: true + next: + type: free + required: true + next: + type: free + required: true + next: + type: url + - *ptag + - *rtag + - + name: text-track + next: + type: json + required: true + next: + type: relay + + 40: + description: Channel Creation + content: + type: json + tags: [] + + 41: + description: Channel Metadata + content: + type: json + tags: + - + name: e + next: + type: id + required: true + next: + type: relay + next: + type: constrained + either: + - root + + 42: + description: Channel Message + content: + type: free + tags: + - + name: e + next: + type: id + required: true + next: + type: relay + next: + type: constrained + either: + - root + - reply + - *ptag + + 43: + description: Channel Hide Message + content: + type: json + tags: + - + name: e + next: + type: id + required: true + + 44: + description: Channel Mute User + content: + type: json + tags: + - *ptag + + 64: + description: Chess (PGN) + content: + type: free + tags: + - *ptag + + 818: + description: Wiki merge requests + in_use: true + content: + type: free + tags: + - *atag + - *ptag + - *etag + + 1018: + description: Poll Response + in_use: true + content: + type: empty + tags: + - *etag + - + name: response + next: + type: free + required: true + + 1021: + description: Bid + content: + type: free + tags: + - *etag + + 1022: + description: Bid confirmation + content: + type: json + tags: + - *etag + + 1040: + description: OpenTimestamps + in_use: true + content: + type: free + tags: + - *etag + - *ktag + + 1222: + description: Voice Message + in_use: true + content: + type: free + tags: [] + + 1244: + description: Voice Message Comment + in_use: true + content: + type: free + tags: + - + name: A + next: *addr + - + name: a + next: *addr + - + name: E + next: *event + - + name: e + next: *event + - + name: I + next: *external + - + name: i + next: *external + - + name: K + next: *kind + - + name: K + next: + type: free + required: true + - *ktag + - + name: P + next: *profile + - *ptag + + 1311: + description: Live Chat Message + in_use: true + content: + type: free + tags: [] + + 1337: + description: Code Snippet + in_use: true + content: + type: free + tags: [] + + 1971: + description: Problem Tracker + content: + type: free + tags: [] + + 1986: + description: Relay reviews + content: + type: free + tags: [] + + 1987: + description: AI Embeddings / Vector lists + content: + type: free + tags: [] + + 2003: + description: Torrent + in_use: true + content: + type: free + tags: + - *titletag + - + name: x + next: + type: free + required: true + - + name: file + next: + type: free + required: true + next: + type: free + - + name: tracker + next: + type: url + - + name: i + next: + type: free + + 2004: + description: Torrent Comment + content: + type: free + tags: + - *etag + + 2022: + description: Coinjoin Pool + content: + type: free + tags: [] + + 4550: + description: Community Post Approval + content: + type: free + tags: [] + + 7374: + description: Reserved Cashu Wallet Tokens + in_use: true + content: + type: free + tags: [] + + 7375: + description: Cashu Wallet Tokens + in_use: true + content: + type: free + tags: [] + + 7376: + description: Cashu Wallet History + in_use: true + content: + type: free + tags: [] + + 7516: + description: Geocache log + content: + type: free + tags: [] + + 7517: + description: Geocache proof of find + content: + type: free + tags: [] + + 9321: + description: Nutzap + in_use: true + content: + type: free + tags: + - + name: proof + next: + type: free + required: true + variadic: true + - + name: u + next: + type: url + required: true + - *etag-bare + - *ktag + - *ptag + + 9467: + description: Tidal login + content: + type: free + tags: [] + + 1059: + description: Gift Wrap + in_use: true + content: + type: free + tags: + - *ptag + + 1063: + description: File Metadata + content: + type: free + tags: + - + name: url + next: + type: url + required: true + - + name: m + next: + type: free + required: true + - + name: x + next: + type: free + required: true + - + name: ox + next: + type: free + required: true + - + name: size + next: + type: free + required: true + - + name: dim + next: + type: free + required: true + - + name: magnet + next: + type: url + required: true + - + name: i + next: + type: free + required: true + - + name: blurhash + next: + type: free + required: true + - + name: thumb + next: + type: url + required: true + - *imagetag + - *summarytag + - + name: fallback + next: + type: url + required: true + - + name: service + next: + type: free + required: true + + 1068: + description: Poll + in_use: true + content: + type: free + tags: + - + name: option + next: + type: free + required: true + next: + type: free + required: true + - *relaytag + - + name: polltype + next: + type: constrained + either: + - singlechoice + - multiplechoice + - + name: endsAt + next: + type: free + required: true + + 1621: + description: Issues + content: + type: free + tags: + - *atag + - *ptag + - + name: subject + next: + type: free + required: true + + 1984: + description: Reporting + in_use: true + content: + type: free + tags: + - *ptag + - *etag + - *atag + + 1985: + description: Label + content: + type: free + tags: + - *ptag + - *etag + - *atag + + 9041: + description: Zap Goal + content: + type: free + tags: + - + name: amount + next: + type: free + required: true + - + name: relays + next: + type: relay + variadic: true + required: true + - + name: closed_at + next: + type: free + - *imagetag + - *summarytag + - *rtag + - *atag + - + name: zap + next: + type: pubkey + required: true + next: + type: relay + next: + type: free + + 9734: + description: Zap Request + in_use: true + content: + type: free + tags: + - + name: relays + next: + type: relay + variadic: true + required: true + - + name: amount + next: + type: free + - + name: lnurl + next: + type: free + - *ptag + - *etag + - *atag + - *ktag + + 9735: + description: Zap + in_use: true + content: + type: empty + tags: + - *ptag + - + name: P + next: *profile + - *etag + - *ktag + - + name: bolt11 + next: + type: free + required: true + - + name: description + next: + type: json + required: true + - + name: preimage + next: + type: free + + 1111: + description: Comment + in_use: true + content: + type: free + tags: + - + name: A + next: *addr + - *atag + - + name: E + next: *event + - *etag + - + name: I + next: *external + - + name: i + next: *external + - + name: K + next: *kind + - + name: K + next: + type: free + required: true + - *ktag + - + name: P + next: *profile + - *ptag + - *emojitag + + 10002: + description: Relay List Metadata + in_use: true + content: + type: empty + tags: + - + name: r + next: + type: relay + required: true + next: + type: constrained + either: + - read + - write + + 9802: + description: Highlights + in_use: true + content: + type: free + tags: + - *ptag + - *etag + - *atag + - *rtag + - + name: context + next: + type: free + - + name: comment + next: + type: free + + 27235: + description: HTTP Auth + in_use: true + content: + type: free + tags: + - + name: u + next: + type: url + required: true + - + name: method + next: + type: constrained + either: + - GET + - POST + - PUT + - DELETE + - PATCH + required: true + - + name: payload + next: + type: free + + 10000: + description: Mute list + in_use: true + content: + type: free + tags: + - *ptag + - name: word + next: + type: free + required: true + - *etag + + 10001: + description: Pin list + in_use: true + content: + type: empty + tags: + - *etag + + 10003: + description: Bookmark list + in_use: true + content: + type: free + tags: + - *etag + - *atag + - *rtag + + 10004: + description: Communities list + content: + type: free + tags: + - *atag + + 10005: + description: Public chats list + content: + type: free + tags: + - *etag + + 10006: + description: Blocked relays list + content: + type: free + tags: + - *relaytag + + 10007: + description: Search relays list + in_use: true + content: + type: free + tags: + - *relaytag + + 10012: + description: Favorite relays list + in_use: true + content: + type: free + tags: + - *relaytag + - *atag + + 10015: + description: Interests list + in_use: true + content: + type: free + tags: + - *atag + + 10020: + description: Media follows + content: + type: free + tags: + - *ptag + + 10030: + description: User emoji list + in_use: true + content: + type: free + tags: + - *emojitag + - *atag + + 10050: + description: Relay list to receive DMs + in_use: true + content: + type: empty + tags: + - *relaytag + + 10101: + description: Good wiki authors + content: + type: empty + tags: + - *ptag + + 10102: + description: Good wiki relays + content: + type: empty + tags: + - *relaytag + + 10009: + description: User groups + in_use: true + content: + type: empty + tags: + - + name: group + next: + type: free + required: true + next: + type: relay + next: + type: free + + 10013: + description: Private event relay list + content: + type: empty + tags: [] + + 10019: + description: Nutzap Mint Recommendation + in_use: true + content: + type: empty + tags: + - *relaytag + - + name: mint + next: + type: url + required: true + - + name: pubkey + next: + type: pubkey + required: true + + 10063: + description: Blossom server list + in_use: true + content: + type: empty + tags: + - + name: server + next: + type: url + required: true + + 10096: + description: File storage server list + content: + type: empty + tags: + - + name: server + next: + type: url + required: true + + 10166: + description: Relay Monitor Announcement + content: + type: empty + tags: [] + + 10312: + description: Room Presence + content: + type: empty + tags: [] + + 10377: + description: Proxy Announcement + content: + type: empty + tags: [] + + 11111: + description: Transport Method Announcement + content: + type: empty + tags: [] + + 13194: + description: Wallet Info + in_use: true + content: + type: free + tags: [] + + 17375: + description: Cashu Wallet Event + in_use: true + content: + type: free + tags: [] + + 21000: + description: Lightning Pub RPC + content: + type: free + tags: [] + + 22242: + description: Client Authentication + in_use: true + content: + type: free + tags: [] + + 23194: + description: Wallet Request + content: + type: free + tags: [] + + 23195: + description: Wallet Response + content: + type: free + tags: [] + + 24133: + description: Nostr Connect + content: + type: free + tags: [] + + 24242: + description: Blobs stored on mediaservers + content: + type: free + tags: [] + + 30008: + description: Profile Badges + in_use: true + content: + type: empty + tags: + - *atag + - *etag + + 30009: + description: Badge Definition + in_use: true + content: + type: free + tags: + - + name: name + next: + type: free + required: true + - *descriptiontag + - *imagetag + - + name: thumb + next: + type: url + next: + type: free + + 30017: + description: Create or update a stall + content: + type: json + tags: [] + + 30018: + description: Create or update a product + content: + type: json + tags: [] + + 30020: + description: Product sold as an auction + content: + type: json + tags: [] + + 30023: + description: Long-form Content + in_use: true + content: + type: free + tags: + - *titletag + - *imagetag + - *summarytag + - *publishedattag + - *etag + - *atag + + 30024: + description: Draft Long-form Content + content: + type: free + tags: + - *titletag + - *imagetag + - *summarytag + - *publishedattag + - *etag + - *atag + + 30078: + description: Application-specific Data + in_use: true + content: + type: free + tags: [] + + 30315: + description: User Statuses + content: + type: free + tags: + - *rtag + - *ptag + - *etag + - *atag + - *emojitag + + + 30402: + description: Classified Listing + content: + type: free + tags: + - *titletag + - *summarytag + - *publishedattag + - + name: location + next: + type: free + required: true + - + name: price + next: + type: free + required: true + next: + type: free + required: true + next: + type: free + - + name: status + next: + type: constrained + either: + - active + - sold + - *imagetag + - + name: g + next: + type: free + - *etag + - *atag + + 30403: + description: Draft Classified Listing + content: + type: free + tags: + - *titletag + - *summarytag + - *publishedattag + - + name: location + next: + type: free + required: true + - + name: price + next: + type: free + required: true + next: + type: free + required: true + next: + type: free + - + name: status + next: + type: constrained + either: + - active + - sold + - *imagetag + - *gtag + - *etag + - *atag + + 31922: + description: Date-Based Calendar Event + in_use: true + content: + type: free + required: + - title + - start + - location + tags: + - *titletag + - *summarytag + - *imagetag + - + name: location + next: + type: free + required: true + - *gtag + - *ptag + - + name: start + next: + type: date + required: true + - + name: end + next: + type: date + required: true + + 31923: + description: Time-Based Calendar Event + in_use: true + content: + type: free + required: + - title + - start + - location + tags: + - *titletag + - *summarytag + - *imagetag + - + name: location + next: + type: free + - *gtag + - *ptag + - + name: start + next: + type: timestamp + required: true + - + name: end + next: + type: timestamp + required: true + - + name: start_tzid + next: + type: free + required: true + - + name: end_tzid + next: + type: free + required: true + 31924: + description: Calendar + in_use: true + content: + type: free + required: + - title + tags: + - *titletag + - *atag + - *imagetag + - *descriptiontag + + 31925: + description: Calendar Event RSVP + in_use: true + content: + type: free + required: + - a + - status + tags: + - *etag + - *atag + - + name: status + next: + type: constrained + either: + - accepted + - declined + - tentative + required: true + - + name: fb + next: + type: constrained + either: + - free + - busy + - *ptag + + 31989: + description: Handler recommendation + content: + type: empty + tags: + - *atag + + 31990: + description: Handler information + content: + type: json + tags: + - *ktag + - + name: web + next: + type: url + required: true + next: + type: constrained + either: + - nevent + - nprofile + - + name: ios + next: + type: free + required: true + + 30617: + description: Repository announcements + in_use: true + content: + type: empty + tags: + - + name: name + next: + type: free + required: true + - *descriptiontag + - + name: web + next: + type: url + required: true + - + name: clone + next: + type: giturl + required: true + - + name: relays + next: + type: relay + variadic: true + - + name: r + next: + type: hex + min: 40 + max: 40 + required: true + next: + type: constrained + either: + - euc + required: true + - + name: maintainers + next: + type: pubkey + variadic: true + + 30618: + description: Repository state announcements + in_use: true + content: + type: empty + tags: + - + prefix: "refs/" + next: + type: hex + min: 40 + max: 40 + required: true + - + name: HEAD + next: + type: free + + 1617: + description: Patches + in_use: true + content: + type: free + tags: + - *atag + - + name: r + next: + type: hex + min: 40 + max: 40 + required: true + - *ptag + - + name: t + next: + type: constrained + either: + - root + - root-revision + required: true + - + name: commit + next: + type: hex + min: 40 + max: 40 + required: true + - + name: parent-commit + next: + type: hex + min: 40 + max: 40 + required: true + - + name: commit-pgp-sig + next: + type: free + required: true + - + name: committer + next: + type: free + required: true + next: + type: free + required: true + next: + type: free + required: true + next: + type: free + required: true + + 30000: + description: Follow sets + in_use: true + content: + type: empty + tags: + - *ptag + - *titletag + - *imagetag + - *descriptiontag + + 30002: + description: Relay sets + in_use: true + content: + type: empty + tags: + - *relaytag + - *titletag + - *imagetag + - *descriptiontag + + 30003: + description: Bookmark sets + content: + type: empty + tags: + - *etag + - *atag + - *rtag + - *titletag + - *imagetag + - *descriptiontag + + 30004: + description: Curation sets + content: + type: empty + tags: + - *atag + - *etag + - *titletag + - *imagetag + - *descriptiontag + + 30005: + description: Video sets + content: + type: empty + tags: + - *atag + - *titletag + - *imagetag + - *descriptiontag + + 30007: + description: Kind mute sets + content: + type: empty + tags: + - *ptag + - *titletag + - *imagetag + - *descriptiontag + + 30015: + description: Interest sets + content: + type: empty + tags: + - *titletag + - *imagetag + - *descriptiontag + + 30019: + description: Marketplace UI/UX + content: + type: json + tags: [] + + 30030: + description: Emoji sets + in_use: true + content: + type: empty + tags: + - *emojitag + - *titletag + - *imagetag + - *descriptiontag + + 30040: + description: Curated Publication Index + content: + type: free + tags: [] + + 30041: + description: Curated Publication Content + content: + type: free + tags: [] + + 30063: + description: Release artifact sets + content: + type: empty + tags: + - *etag + - *atag + - *titletag + - *imagetag + - *descriptiontag + + 30166: + description: Relay Discovery + content: + type: empty + tags: [] + + 30267: + description: App curation sets + content: + type: empty + tags: + - *atag + - *titletag + - *imagetag + - *descriptiontag + + 30311: + description: Live Event + in_use: true + content: + type: free + tags: [] + + 30312: + description: Interactive Room + content: + type: free + tags: [] + + 30313: + description: Conference Event + content: + type: free + tags: [] + + 30388: + description: Slide Set + content: + type: free + tags: [] + + 30818: + description: Wiki article + in_use: true + content: + type: free + tags: + - *titletag + - *summarytag + - *atag + - *etag + + 30819: + description: Wiki Redirects + in_use: true + content: + type: free + tags: [] + + 31234: + description: Draft Event + content: + type: free + tags: [] + + 31388: + description: Link Set + content: + type: free + tags: [] + + 31890: + description: Feed + content: + type: free + tags: [] + + 32267: + description: Software Application + content: + type: free + tags: [] + + 34550: + description: Community Definition + content: + type: free + tags: [] + + 37516: + description: Geocache listing + content: + type: free + tags: [] + + 38172: + description: Cashu Mint Announcement + content: + type: free + tags: [] + + 38173: + description: Fedimint Announcement + content: + type: free + tags: [] + + 38383: + description: Peer-to-peer Order events + content: + type: free + tags: [] + + 39089: + description: Starter packs + in_use: true + content: + type: empty + tags: + - *ptag + - *titletag + - *imagetag + - *descriptiontag + + 39092: + description: Media starter packs + content: + type: empty + tags: + - *ptag + - *titletag + - *imagetag + - *descriptiontag + + 39701: + description: Web bookmarks + in_use: true + content: + type: free + tags: [] diff --git a/src/hooks/useLiveTimeline.ts b/src/hooks/useLiveTimeline.ts index 8955815..d5664c5 100644 --- a/src/hooks/useLiveTimeline.ts +++ b/src/hooks/useLiveTimeline.ts @@ -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(null); - const [eoseReceived, setEoseReceived] = useState(false); + const eventStore = useEventStore(); + const { limit, stream = false } = options; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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, + }; } diff --git a/src/lib/nostr-schema.test.ts b/src/lib/nostr-schema.test.ts new file mode 100644 index 0000000..1eb5352 --- /dev/null +++ b/src/lib/nostr-schema.test.ts @@ -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 "); + }); + + it("should format tag with multiple values", () => { + const result = formatTag({ + name: "p", + next: { + type: "pubkey", + required: true, + next: { + type: "relay", + }, + }, + }); + expect(result).toBe("#p "); + }); + + it("should indicate variadic tags", () => { + const result = formatTag({ + name: "t", + variadic: true, + next: { + type: "free", + required: true, + }, + }); + expect(result).toBe("#t (multiple)"); + }); + + it("should format constrained types", () => { + const result = formatTag({ + name: "status", + next: { + type: "constrained", + either: ["accepted", "declined"], + }, + }); + expect(result).toBe("#status "); + }); + + it("should convert 'free' type to 'text'", () => { + const result = formatTag({ + name: "subject", + next: { + type: "free", + required: true, + }, + }); + expect(result).toBe("#subject "); + }); + }); + + 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)" + ); + }); + }); +}); diff --git a/src/lib/nostr-schema.ts b/src/lib/nostr-schema.ts new file mode 100644 index 0000000..c106222 --- /dev/null +++ b/src/lib/nostr-schema.ts @@ -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; + +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"; + } +} diff --git a/src/types/man.ts b/src/types/man.ts index 938a9d2..45602df 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -39,7 +39,7 @@ export const manPages: Record = { "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 = { "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 = { synopsis: "help", description: "Display general help information about Grimoire and available commands.", - examples: [ - "Use man 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 = { }, ], 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 = { }, ], 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 = { }, ], 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 = { }, ], 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 = { }, ], 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 = { }, ], 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 = { ], 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"],