mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 06:57:07 +02:00
feat: kind schemas and better man pages
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
2062
src/data/nostr-kinds-schema.yaml
Normal file
2062
src/data/nostr-kinds-schema.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
219
src/lib/nostr-schema.test.ts
Normal file
219
src/lib/nostr-schema.test.ts
Normal 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
154
src/lib/nostr-schema.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user