feat: kind schemas and better man pages

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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