This commit is contained in:
Alejandro Gómez
2025-11-26 09:47:21 +01:00
commit cd41034b2f
112 changed files with 18581 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_NOSTR_RELAY=wss://theforest.nostr1.com

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

165
CLAUDE.md Normal file
View File

@@ -0,0 +1,165 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Grimoire** is a Nostr client built with React, TypeScript, Vite, and TailwindCSS. It connects to Nostr relays to fetch and display events (notes) with rich text formatting and user profile integration.
## Technology Stack
- **Frontend Framework**: React 18 with TypeScript
- **Build Tool**: Vite 6
- **Styling**: TailwindCSS 3 with shadcn/ui design system (New York style)
- **Routing**: React Router 7
- **Nostr Integration**: Applesauce library suite
- `applesauce-relay`: Relay connection and event subscription
- `applesauce-core`: Event storage and deduplication
- `applesauce-react`: React hooks for content rendering
- `applesauce-content`: Content parsing utilities
- **State Management**: RxJS Observables for reactive event streams
- **Icons**: Lucide React
## Development Commands
```bash
# Start development server
npm run dev
# Build for production
npm run build
# Lint code
npm run lint
# Preview production build
npm run preview
```
## Architecture
### Service Layer (Idiomatic Applesauce)
Simple singleton exports for global instances:
**`src/services/event-store.ts`** - Global EventStore instance
- Centralized event cache and deduplication
- Accessed via `useEventStore()` hook in components
**`src/services/relay-pool.ts`** - Global RelayPool instance
- Manages WebSocket connections to Nostr relays
- Used by loaders for event fetching
**`src/services/loaders.ts`** - Pre-configured loaders
- `eventLoader` - Fetches single events by ID
- `addressLoader` - Fetches replaceable events (kind:pubkey:d-tag)
- `profileLoader` - Fetches profiles with 200ms batching
- `createTimelineLoader` - Factory for creating timeline loaders
- Uses `AGGREGATOR_RELAYS` for better event discovery
### Provider Setup
**EventStoreProvider** (`src/main.tsx`) - Wraps app to provide EventStore via React context
- All components access store through `useEventStore()` hook
- Enables reactive updates when events are added to store
### Loader Pattern (Efficient Data Fetching)
Loaders from `applesauce-loaders` provide:
- **Automatic batching**: Multiple profile requests within 200ms window combined into single relay query
- **Smart relay selection**: Uses event hints, relay lists, and aggregator relays
- **Deduplication**: Won't refetch events already in store
- **Observable streams**: Returns RxJS observables for reactive updates
### React Hooks Pattern (Observable-Based)
Three primary custom hooks provide reactive Nostr data:
**`useTimeline(id, filters, relays, options)`** - Subscribe to event timeline
- Returns: `{ events, loading, error }`
- Uses `createTimelineLoader` for efficient batch loading
- Watches EventStore with `useObservableMemo` for reactive updates
- Auto-sorts by `created_at` descending
**`useProfile(pubkey)`** - Fetch user profile metadata (kind 0)
- Returns: `ProfileMetadata | undefined`
- Uses `profileLoader` with automatic batching
- Subscribes to `ProfileModel` in EventStore for reactive updates
**`useNostrEvent(eventId, relayUrl?)`** - Fetch single event by ID
- Returns: `NostrEvent | undefined`
- Uses `eventLoader` for efficient caching
- Watches EventStore for event arrival
### Component Architecture
**Nostr Components** (`src/components/nostr/`)
- **`UserName`**: Displays user's display name with fallback to pubkey snippet
- **`RichText`**: Renders Nostr event content with rich formatting
- Uses `applesauce-react`'s `useRenderedContent` hook
- Supports: mentions (@npub), hashtags, links, images, videos, emojis, quotes
- Custom content node renderers in `contentComponents` object
**Main Components** (`src/components/`)
- **`Home`**: Main feed component displaying recent notes
### Type System
**Core Types** (`src/types/`)
- `NostrEvent`: Standard Nostr event structure (id, pubkey, created_at, kind, tags, content, sig)
- `NostrFilter`: Relay query filters (ids, authors, kinds, since, until, limit)
- `ProfileMetadata`: User profile fields (name, display_name, about, picture, etc.)
### Utility Functions
**`src/lib/nostr-utils.ts`**
- `derivePlaceholderName(pubkey)`: Creates `"xxxxxxxx..."` placeholder from pubkey
- `getDisplayName(metadata, pubkey)`: Priority logic: display_name → name → placeholder
**`src/lib/utils.ts`**
- `cn()`: TailwindCSS class merger using `clsx` and `tailwind-merge`
## Path Aliases
All imports use `@/` prefix for `src/` directory:
```typescript
import { nostrService } from '@/services/nostr'
import { useProfile } from '@/hooks/useProfile'
import { cn } from '@/lib/utils'
```
## Environment Configuration
`.env` file should contain:
```
VITE_NOSTR_RELAY=wss://theforest.nostr1.com
```
Access via: `import.meta.env.VITE_NOSTR_RELAY`
## Styling System
- **Dark Mode**: Default theme with `dark` class on `<html>` element
- **Design System**: shadcn/ui (New York variant) with HSL CSS variables
- **Color Palette**: Semantic tokens (background, foreground, primary, secondary, muted, accent, destructive)
- **Font**: Oxygen Mono for monospace text
- **Utilities**: Use `cn()` helper for conditional classes
## Key Patterns
1. **Global EventStore**: Single source of truth for all events, accessed via `useEventStore()` hook
2. **Loader-Based Fetching**: Use loaders instead of direct subscriptions for automatic batching and caching
3. **Observable Reactivity**: Use `useObservableMemo()` to watch EventStore and auto-update on changes
4. **Automatic Batching**: Profile and event requests batched within 200ms window
5. **Event Deduplication**: Handled automatically by EventStore, no manual checks needed
6. **Fallback UI**: Show placeholders for missing profile data, handle loading/error states
7. **Rich Content Rendering**: Delegate to `applesauce-react` for Nostr content parsing
## Important Notes
- All components must be wrapped in `EventStoreProvider` to access the store
- Loaders automatically handle subscription cleanup, but always unsubscribe in `useEffect` cleanup
- EventStore provides reactive queries: `.event(id)`, `.replaceable(kind, pubkey, d)`, `.timeline(filters)`
- Profile requests are batched - multiple `useProfile` calls within 200ms become single relay query
- The RichText component requires the full event object, not just content string
- Use `useObservableMemo()` for reactive store queries, not `useState` + subscriptions

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Grimoire - Nostr Client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8650
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "grimoire",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4",
"applesauce-accounts": "^4.1.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "latest",
"applesauce-loaders": "^4.2.0",
"applesauce-react": "^4.0.0",
"applesauce-relay": "latest",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dexie": "^4.2.1",
"dexie-react-hooks": "^4.2.0",
"jotai": "^2.15.2",
"lucide-react": "latest",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-medium-image-zoom": "^5.4.0",
"react-mosaic-component": "^6.1.1",
"react-router": "^7.1.0",
"remark-gfm": "^4.0.1",
"rxjs": "^7.8.1",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@react-router/dev": "^7.1.0",
"@types/node": "^24.10.1",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { useGrimoire } from "@/core/state";
import { manPages } from "@/types/man";
import { AppId } from "@/types/app";
interface CommandProps {
name: string;
args?: string;
description: string;
appId?: AppId;
props?: any;
commandLine?: string; // Full command with args (e.g., "decode npub1...")
}
export default function Command({
name,
args,
description,
appId,
props,
commandLine,
}: CommandProps) {
const [showTooltip, setShowTooltip] = useState(false);
const { addWindow } = useGrimoire();
const handleClick = async () => {
if (commandLine) {
// Parse and execute the full command line
const parts = commandLine.trim().split(/\s+/);
const commandName = parts[0]?.toLowerCase();
const cmdArgs = parts.slice(1);
const command = manPages[commandName];
if (command) {
// argParser can now be async
const cmdProps = command.argParser
? await Promise.resolve(command.argParser(cmdArgs))
: command.defaultProps || {};
const title =
cmdArgs.length > 0
? `${commandName.toUpperCase()} ${cmdArgs.join(" ")}`
: commandName.toUpperCase();
addWindow(command.appId, cmdProps, title);
}
} else if (appId) {
// Open the specified app with given props
addWindow(appId, props || {}, name.toUpperCase());
} else {
// Default: open man page
addWindow("man", { cmd: name }, `MAN ${name}`);
}
};
return (
<div className="relative inline-block">
<button
className="px-2 py-1 border border-accent text-accent hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer font-mono text-sm uppercase"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={handleClick}
>
{name}
</button>
{showTooltip && (
<div className="absolute z-10 top-full mt-2 left-0 bg-popover border border-border p-3 shadow-lg min-w-64">
<div className="font-mono text-xs space-y-1">
<div className="text-primary font-semibold">
{name}{" "}
{args && <span className="text-muted-foreground">{args}</span>}
</div>
<div className="text-muted-foreground">{description}</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { useGrimoire } from "@/core/state";
import { manPages } from "@/types/man";
import "./command-launcher.css";
interface CommandLauncherProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function CommandLauncher({
open,
onOpenChange,
}: CommandLauncherProps) {
const [input, setInput] = useState("");
const { addWindow } = useGrimoire();
useEffect(() => {
if (!open) {
setInput("");
}
}, [open]);
// Parse input into command and arguments
const parseInput = (value: string) => {
const parts = value.trim().split(/\s+/);
const commandName = parts[0]?.toLowerCase() || "";
const args = parts.slice(1);
return { commandName, args, fullInput: value };
};
const { commandName, args } = parseInput(input);
const recognizedCommand = commandName && manPages[commandName];
// Filter commands by partial match on command name only
const filteredCommands = Object.entries(manPages).filter(([name]) =>
name.toLowerCase().includes(commandName.toLowerCase()),
);
// Execute command (async to support async argParsers)
const executeCommand = async () => {
if (!recognizedCommand) return;
const command = recognizedCommand;
// Use argParser if available, otherwise use defaultProps
// argParser can now be async
const props = command.argParser
? await Promise.resolve(command.argParser(args))
: command.defaultProps || {};
// Generate title
const title =
args.length > 0
? `${commandName.toUpperCase()} ${args.join(" ")}`
: commandName.toUpperCase();
// Execute command
addWindow(command.appId, props, title);
onOpenChange(false);
};
// Handle item selection (populate input, don't execute)
const handleSelect = (selectedCommand: string) => {
setInput(selectedCommand + " ");
};
// Handle Enter key
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
executeCommand();
}
};
// Define category order: Nostr first, then Documentation, then System
const categoryOrder = ["Nostr", "Documentation", "System"];
const categories = Array.from(
new Set(filteredCommands.map(([_, cmd]) => cmd.category)),
).sort((a, b) => {
const indexA = categoryOrder.indexOf(a);
const indexB = categoryOrder.indexOf(b);
return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB);
});
// Dynamic placeholder
const placeholder = recognizedCommand
? recognizedCommand.synopsis
: "Type a command...";
return (
<Command.Dialog
open={open}
onOpenChange={onOpenChange}
label="Command Launcher"
className="grimoire-command-launcher"
shouldFilter={false}
>
<div className="command-launcher-wrapper">
<Command.Input
value={input}
onValueChange={setInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="command-input"
/>
{recognizedCommand && args.length > 0 && (
<div className="command-hint">
<span className="command-hint-label">Parsed:</span>
<span className="command-hint-command">{commandName}</span>
<span className="command-hint-args">{args.join(" ")}</span>
</div>
)}
<Command.List className="command-list">
<Command.Empty className="command-empty">
{commandName
? `No command found: ${commandName}`
: "Start typing..."}
</Command.Empty>
{categories.map((category) => (
<Command.Group
key={category}
heading={category}
className="command-group"
>
{filteredCommands
.filter(([_, cmd]) => cmd.category === category)
.map(([name, cmd]) => {
const isExactMatch = name === commandName;
return (
<Command.Item
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="command-item"
data-exact-match={isExactMatch}
>
<div className="command-item-content">
<div className="command-item-name">
<span className="command-name">{name}</span>
{cmd.synopsis !== name && (
<span className="command-args">
{cmd.synopsis.replace(name, "").trim()}
</span>
)}
{isExactMatch && (
<span className="command-match-indicator"></span>
)}
</div>
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
</div>
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="command-footer">
<div>
<kbd></kbd> navigate
<kbd></kbd> execute
<kbd>esc</kbd> close
</div>
{recognizedCommand && (
<div className="command-footer-status">Ready to execute</div>
)}
</div>
</div>
</Command.Dialog>
);
}

View File

@@ -0,0 +1,341 @@
import { useState, useMemo } from "react";
import { Copy, Check, Plus, X, ExternalLink } from "lucide-react";
import {
parseDecodeCommand,
decodeNostr,
reencodeWithRelays,
type DecodedData,
} from "@/lib/decode-parser";
import { useGrimoire } from "@/core/state";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface DecodeViewerProps {
args: string[];
}
export default function DecodeViewer({ args }: DecodeViewerProps) {
const { addWindow } = useGrimoire();
const [copied, setCopied] = useState(false);
const [relays, setRelays] = useState<string[]>([]);
const [newRelay, setNewRelay] = useState("");
const [error, setError] = useState<string | null>(null);
// Parse and decode
const decoded = useMemo<{ bech32: string; data: DecodedData } | null>(() => {
try {
const parsed = parseDecodeCommand(args);
const data = decodeNostr(parsed.bech32);
// Initialize relays from decoded data
if (data.type === "nprofile") {
setRelays(data.data.relays || []);
} else if (data.type === "nevent") {
setRelays(data.data.relays || []);
} else if (data.type === "naddr") {
setRelays(data.data.relays || []);
}
setError(null);
return { bech32: parsed.bech32, data };
} catch (err) {
setError(err instanceof Error ? err.message : "Decode error");
return null;
}
}, [args]);
// Re-encode with current relays
const reencoded = useMemo(() => {
if (!decoded) return null;
try {
return reencodeWithRelays(decoded.data, relays, decoded.bech32);
} catch {
return decoded.bech32;
}
}, [decoded, relays]);
const copyToClipboard = () => {
if (reencoded) {
navigator.clipboard.writeText(reencoded);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const addRelay = () => {
if (!newRelay.trim()) return;
// Auto-add wss:// if no protocol
let relayUrl = newRelay.trim();
if (!relayUrl.startsWith("ws://") && !relayUrl.startsWith("wss://")) {
relayUrl = `wss://${relayUrl}`;
}
try {
const url = new URL(relayUrl);
if (!url.protocol.startsWith("ws")) {
setError("Relay must be a WebSocket URL (ws:// or wss://)");
return;
}
setRelays([...relays, relayUrl]);
setNewRelay("");
setError(null);
} catch {
setError("Invalid relay URL");
}
};
const removeRelay = (index: number) => {
setRelays(relays.filter((_, i) => i !== index));
};
const openEvent = () => {
if (!decoded) return;
if (decoded.data.type === "note") {
addWindow(
"open",
{ pointer: { id: decoded.data.data, relays } },
`Event ${decoded.data.data.slice(0, 8)}...`,
);
} else if (decoded.data.type === "nevent") {
addWindow(
"open",
{ pointer: { id: decoded.data.data.id, relays } },
`Event ${decoded.data.data.id.slice(0, 8)}...`,
);
} else if (decoded.data.type === "naddr") {
const { kind, pubkey, identifier } = decoded.data.data;
addWindow(
"open",
{ pointer: { kind, pubkey, identifier, relays } },
`${kind}:${pubkey.slice(0, 8)}:${identifier}`,
);
}
};
const openProfile = () => {
if (!decoded) return;
let pubkey: string | undefined;
if (decoded.data.type === "npub") {
pubkey = decoded.data.data;
} else if (decoded.data.type === "nprofile") {
pubkey = decoded.data.data.pubkey;
} else if (decoded.data.type === "naddr") {
pubkey = decoded.data.data.pubkey;
}
if (pubkey) {
addWindow("profile", { pubkey }, `Profile ${pubkey.slice(0, 8)}...`);
}
};
if (error) {
return (
<div className="h-full w-full flex flex-col bg-background text-foreground p-4">
<div className="text-destructive text-sm font-mono">{error}</div>
</div>
);
}
if (!decoded) {
return (
<div className="h-full w-full flex flex-col bg-background text-foreground p-4">
<div className="text-muted-foreground text-sm">Loading...</div>
</div>
);
}
const { type, data } = decoded.data;
const supportsRelays = ["nprofile", "nevent", "naddr"].includes(type);
const canOpenEvent = ["note", "nevent", "naddr"].includes(type);
const canOpenProfile = ["npub", "nprofile", "naddr"].includes(type);
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
{/* Header */}
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">DECODE {type.toUpperCase()}</h2>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Decoded Information */}
<div className="space-y-2">
<div className="text-xs text-muted-foreground font-semibold">
Decoded Data
</div>
{type === "npub" && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Public Key</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{data}
</div>
</div>
)}
{type === "note" && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Event ID</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{data}
</div>
</div>
)}
{type === "nsec" && (
<div className="space-y-1">
<div className="text-xs text-destructive font-semibold">
Private Key (Keep Secret!)
</div>
<div className="bg-destructive/10 p-3 rounded font-mono text-xs break-all border border-destructive">
{data}
</div>
</div>
)}
{type === "nprofile" && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Public Key</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{(data as any).pubkey}
</div>
</div>
)}
{type === "nevent" && (
<>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Event ID</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{(data as any).id}
</div>
</div>
{(data as any).author && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Author</div>
<div className="bg-muted p-2 rounded font-mono text-xs break-all">
{(data as any).author}
</div>
</div>
)}
</>
)}
{type === "naddr" && (
<>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Kind</div>
<div className="bg-muted p-2 rounded font-mono text-xs">
{(data as any).kind}
</div>
</div>
<div className="space-y-1 col-span-2">
<div className="text-xs text-muted-foreground">
Identifier
</div>
<div className="bg-muted p-2 rounded font-mono text-xs truncate">
{(data as any).identifier}
</div>
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Public Key</div>
<div className="bg-muted p-2 rounded font-mono text-xs break-all">
{(data as any).pubkey}
</div>
</div>
</>
)}
</div>
{/* Relay Editor */}
{supportsRelays && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground font-semibold">
Relays ({relays.length})
</div>
<div className="space-y-2">
{relays.map((relay, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1 bg-muted p-2 rounded font-mono text-xs truncate">
{relay}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeRelay(index)}
>
<X className="size-3" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Input
placeholder="wss://relay.example.com"
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addRelay()}
className="font-mono text-xs"
/>
<Button size="sm" onClick={addRelay}>
<Plus className="size-3" />
</Button>
</div>
</div>
</div>
)}
{/* Updated Identifier */}
<div className="space-y-2">
<div className="text-xs text-muted-foreground font-semibold">
{supportsRelays && relays.length > 0
? "Updated Identifier"
: "Original Identifier"}
</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all border border-accent">
{reencoded}
</div>
<Button
size="sm"
onClick={copyToClipboard}
className="w-full"
variant={copied ? "default" : "outline"}
>
{copied ? (
<>
<Check className="size-3 mr-2" />
Copied!
</>
) : (
<>
<Copy className="size-3 mr-2" />
Copy to Clipboard
</>
)}
</Button>
</div>
{/* Actions */}
<div className="flex gap-2">
{canOpenEvent && (
<Button
size="sm"
variant="outline"
onClick={openEvent}
className="flex-1"
>
<ExternalLink className="size-3 mr-2" />
Open Event
</Button>
)}
{canOpenProfile && (
<Button
size="sm"
variant="outline"
onClick={openProfile}
className="flex-1"
>
<ExternalLink className="size-3 mr-2" />
Open Profile
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useState, useMemo } from "react";
import { Copy, Check, Plus, X } from "lucide-react";
import {
parseEncodeCommand,
encodeToNostr,
type ParsedEncodeCommand,
} from "@/lib/encode-parser";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface EncodeViewerProps {
args: string[];
}
export default function EncodeViewer({ args }: EncodeViewerProps) {
const [copied, setCopied] = useState(false);
const [relays, setRelays] = useState<string[]>([]);
const [newRelay, setNewRelay] = useState("");
const [error, setError] = useState<string | null>(null);
// Parse command
const parsed = useMemo<ParsedEncodeCommand | null>(() => {
try {
const result = parseEncodeCommand(args);
setRelays(result.relays || []);
setError(null);
return result;
} catch (err) {
setError(err instanceof Error ? err.message : "Parse error");
return null;
}
}, [args]);
// Generate bech32 with current relays
const encoded = useMemo(() => {
if (!parsed) return null;
try {
return encodeToNostr({
...parsed,
relays: relays.length > 0 ? relays : undefined,
});
} catch (err) {
return null;
}
}, [parsed, relays]);
const copyToClipboard = () => {
if (encoded) {
navigator.clipboard.writeText(encoded);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const addRelay = () => {
if (!newRelay.trim()) return;
// Auto-add wss:// if no protocol
let relayUrl = newRelay.trim();
if (!relayUrl.startsWith("ws://") && !relayUrl.startsWith("wss://")) {
relayUrl = `wss://${relayUrl}`;
}
try {
const url = new URL(relayUrl);
if (!url.protocol.startsWith("ws")) {
setError("Relay must be a WebSocket URL (ws:// or wss://)");
return;
}
setRelays([...relays, relayUrl]);
setNewRelay("");
setError(null);
} catch {
setError("Invalid relay URL");
}
};
const removeRelay = (index: number) => {
setRelays(relays.filter((_, i) => i !== index));
};
if (error) {
return (
<div className="h-full w-full flex flex-col bg-background text-foreground p-4">
<div className="text-destructive text-sm font-mono">{error}</div>
</div>
);
}
if (!parsed) {
return (
<div className="h-full w-full flex flex-col bg-background text-foreground p-4">
<div className="text-muted-foreground text-sm">Loading...</div>
</div>
);
}
const supportsRelays = ["nprofile", "nevent", "naddr"].includes(parsed.type);
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
{/* Header */}
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">
ENCODE {parsed.type.toUpperCase()}
</h2>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Input Information */}
<div className="space-y-2">
<div className="text-xs text-muted-foreground font-semibold">
Input
</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{parsed.value}
</div>
{parsed.author && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Author</div>
<div className="bg-muted p-2 rounded font-mono text-xs break-all">
{parsed.author}
</div>
</div>
)}
</div>
{/* Relay Editor */}
{supportsRelays && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground font-semibold">
Relays ({relays.length})
</div>
<div className="space-y-2">
{relays.map((relay, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1 bg-muted p-2 rounded font-mono text-xs truncate">
{relay}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeRelay(index)}
>
<X className="size-3" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Input
placeholder="wss://relay.example.com"
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addRelay()}
className="font-mono text-xs"
/>
<Button size="sm" onClick={addRelay}>
<Plus className="size-3" />
</Button>
</div>
</div>
</div>
)}
{/* Encoded Result */}
<div className="space-y-2">
<div className="text-xs text-muted-foreground font-semibold">
Result
</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all border border-accent">
{encoded}
</div>
<Button
size="sm"
onClick={copyToClipboard}
className="w-full"
variant={copied ? "default" : "outline"}
>
{copied ? (
<>
<Check className="size-3 mr-2" />
Copied!
</>
) : (
<>
<Copy className="size-3 mr-2" />
Copy to Clipboard
</>
)}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useState } from "react";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./nostr/kinds";
import { Kind0DetailRenderer } from "./nostr/kinds/Kind0DetailRenderer";
import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer";
import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer";
import { KindBadge } from "./KindBadge";
import {
Copy,
ChevronDown,
ChevronRight,
FileJson,
Wifi,
Circle,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { getSeenRelays } from "applesauce-core/helpers/relays";
export interface EventDetailViewerProps {
pointer: EventPointer | AddressPointer;
}
/**
* EventDetailViewer - Detailed view for a single event
* Shows compact metadata header and rendered content
*/
export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
const event = useNostrEvent(pointer);
const [showJson, setShowJson] = useState(false);
const [showRelays, setShowRelays] = useState(false);
// Loading state
if (!event) {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-muted-foreground">
<div className="text-sm">Loading event...</div>
</div>
);
}
// Helper to copy to clipboard
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
// Get relays this event was seen on using applesauce
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : undefined;
// Generate nevent/naddr bech32 ID for display (always use nevent, not note)
const bech32Id =
"id" in pointer
? nip19.neventEncode({
id: event.id,
relays: relays,
author: event.pubkey,
})
: nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: event.tags.find((t) => t[0] === "d")?.[1] || "",
relays: relays,
});
// Format timestamp - compact format
// const timestamp = new Date(event.created_at * 1000).toLocaleString("en-US", {
// month: "2-digit",
// day: "2-digit",
// year: "numeric",
// hour: "2-digit",
// minute: "2-digit",
// });
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Compact Header - Single Line */}
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
{/* Left: Event ID */}
<button
onClick={() => copyToClipboard(bech32Id)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
title={bech32Id}
>
<Copy className="size-3 flex-shrink-0" />
<code className="truncate">
{bech32Id.slice(0, 16)}...{bech32Id.slice(-8)}
</code>
</button>
{/* Right: Kind Badge, Relay Count, and JSON Toggle */}
<div className="flex items-center gap-3 flex-shrink-0">
<div className="flex items-center gap-1">
<KindBadge kind={event.kind} variant="compact" />
<span className="text-xs text-muted-foreground">
<KindBadge
kind={event.kind}
showName
showKindNumber={false}
showIcon={false}
/>
</span>
</div>
{relays && relays.length > 0 && (
<button
onClick={() => setShowRelays(!showRelays)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showRelays ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<Wifi className="size-3" />
<span>{relays.length}</span>
</button>
)}
<button
onClick={() => setShowJson(!showJson)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showJson ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<FileJson className="size-3" />
</button>
</div>
</div>
{/* Expandable Relays */}
{showRelays && relays && relays.length > 0 && (
<div className="border-b border-border px-4 py-2 bg-muted">
<div className="flex flex-col gap-2">
{relays.map((relay) => (
<div key={relay} className="flex items-center gap-2">
<Circle className="size-2 fill-green-500 text-green-500" />
<span className="text-xs font-mono text-muted-foreground">
{relay}
</span>
</div>
))}
</div>
</div>
)}
{/* Expandable JSON */}
{showJson && (
<div className="border-b border-border px-4 py-2 bg-muted">
<div className="flex justify-end mb-2">
<button
onClick={() => copyToClipboard(JSON.stringify(event, null, 2))}
className="hover:text-foreground text-muted-foreground transition-colors text-xs flex items-center gap-1"
>
<Copy className="size-3" />
Copy JSON
</button>
</div>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words bg-background p-2 rounded border border-border font-mono">
{JSON.stringify(event, null, 2)}
</pre>
</div>
)}
{/* Rendered Content - Focus Here */}
<div className="flex-1 overflow-y-auto">
{event.kind === 0 ? (
<Kind0DetailRenderer event={event} />
) : event.kind === 30023 ? (
<Kind30023DetailRenderer event={event} />
) : event.kind === 9802 ? (
<Kind9802DetailRenderer event={event} />
) : (
<KindRenderer event={event} showTimestamp={true} />
)}
</div>
</div>
);
}

203
src/components/Home.tsx Normal file
View File

@@ -0,0 +1,203 @@
import { useState, useEffect } from "react";
import UserMenu from "./nostr/user-menu";
import { useGrimoire } from "@/core/state";
import { useAccountSync } from "@/hooks/useAccountSync";
import Feed from "./nostr/Feed";
import { WinViewer } from "./WinViewer";
import { WindowToolbar } from "./WindowToolbar";
import { TabBar } from "./TabBar";
import { Mosaic, MosaicWindow, MosaicBranch } from "react-mosaic-component";
import { NipRenderer } from "./NipRenderer";
import ManPage from "./ManPage";
import CommandLauncher from "./CommandLauncher";
import ReqViewer from "./ReqViewer";
import { EventDetailViewer } from "./EventDetailViewer";
import { ProfileViewer } from "./ProfileViewer";
import EncodeViewer from "./EncodeViewer";
import DecodeViewer from "./DecodeViewer";
import KindRenderer from "./KindRenderer";
import { Terminal } from "lucide-react";
import { Button } from "./ui/button";
export default function Home() {
const { state, activeWorkspace, updateLayout, removeWindow } = useGrimoire();
const [commandLauncherOpen, setCommandLauncherOpen] = useState(false);
// Sync active account and fetch relay lists
useAccountSync();
// Keyboard shortcut: Cmd/Ctrl+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setCommandLauncherOpen((open) => !open);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
const handleRemoveWindow = (id: string) => {
// Remove from windows map
removeWindow(id);
};
const renderTile = (id: string, path: MosaicBranch[]) => {
const window = state.windows[id];
if (!window) {
return (
<MosaicWindow
path={path}
title="Unknown Window"
toolbarControls={<WindowToolbar />}
>
<div className="p-4 text-muted-foreground">
Window not found: {id}
</div>
</MosaicWindow>
);
}
// Render based on appId
let content;
switch (window.appId) {
case "nip":
content = <NipRenderer nipId={window.props.number} />;
break;
case "feed":
content = <Feed className="h-full w-full overflow-auto" />;
break;
case "win":
content = <WinViewer />;
break;
case "kind":
content = <KindRenderer kind={parseInt(window.props.number)} />;
break;
case "man":
content = <ManPage cmd={window.props.cmd} />;
break;
case "req":
content = (
<ReqViewer
filter={window.props.filter}
relays={window.props.relays}
closeOnEose={window.props.closeOnEose}
nip05Authors={window.props.nip05Authors}
nip05PTags={window.props.nip05PTags}
/>
);
break;
case "open":
content = <EventDetailViewer pointer={window.props.pointer} />;
break;
case "profile":
content = <ProfileViewer pubkey={window.props.pubkey} />;
break;
case "encode":
content = <EncodeViewer args={window.props.args} />;
break;
case "decode":
content = <DecodeViewer args={window.props.args} />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">
Unknown app: {window.appId}
</div>
);
}
return (
<MosaicWindow
path={path}
title={window.title}
toolbarControls={
<WindowToolbar onClose={() => handleRemoveWindow(id)} />
}
>
<div className="h-full w-full overflow-auto">{content}</div>
</MosaicWindow>
);
};
return (
<>
<CommandLauncher
open={commandLauncherOpen}
onOpenChange={setCommandLauncherOpen}
/>
<main className="h-screen w-screen flex flex-col bg-background text-foreground">
<header className="flex flex-row items-center justify-between px-1 border-b border-border">
<button
onClick={() => setCommandLauncherOpen(true)}
className="p-1 text-muted-foreground hover:text-accent transition-colors cursor-pointer"
title="Launch command (Cmd+K)"
>
<Terminal className="size-4" />
</button>
<UserMenu />
</header>
<section className="flex-1 relative overflow-hidden">
{activeWorkspace.layout === null ? (
<div className="h-full w-full flex items-center justify-center">
<div className="flex flex-col items-center gap-8">
<pre className="font-mono text-xs leading-tight text-grimoire-gradient">
{` ★ ✦
: ☽
t#, ,;
✦ .Gt j. t ;##W. t j. f#i
j#W: EW, Ej .. : :#L:WE Ej EW, .E#t
☆ ;K#f E##j E#, ,W, .Et .KG ,#D E#, E##j i#W,
.G#D. E###D. E#t t##, ,W#t EE ;#f E#t E###D. L#D. ✦
j#K; E#jG#W; E#t L###, j###t f#. t#iE#t E#jG#W; :K#Wfff;
,K#f ,GD; E#t t##f E#t .E#j##, G#fE#t :#G GK E#t E#t t##f i##WLLLLt
☽ j#Wi E#t E#t :K#E: E#t ;WW; ##,:K#i E#t ;#L LW. E#t E#t :K#E: .E#L
.G#D: E#t E#KDDDD###iE#t j#E. ##f#W, E#t t#f f#: E#t E#KDDDD###i f#E: ★
,K#fK#t E#f,t#Wi,,,E#t .D#L ###K: E#t f#D#; E#t E#f,t#Wi,,, ,WW;
✦ j###t E#t ;#W: E#t :K#t ##D. E#t G#t E#t E#t ;#W: .D#;
.G#t DWi ,KK: E#t ... #G .. t E#t DWi ,KK: tt
;; ☆ ,;. j ✦ ,;. ☆ `}
</pre>
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground text-sm font-mono mb-2">
Press{" "}
<kbd className="px-2 py-1 bg-muted border border-border text-xs">
Cmd+K
</kbd>{" "}
or
</p>
<Button
onClick={() => setCommandLauncherOpen(true)}
variant="outline"
>
<span></span>
<span>Launch Command</span>
</Button>
</div>
</div>
</div>
) : (
<Mosaic
renderTile={renderTile}
value={activeWorkspace.layout}
onChange={updateLayout}
onRelease={(node) => {
// When Mosaic removes a node from the layout, clean up the window
if (typeof node === "string") {
handleRemoveWindow(node);
}
}}
className="mosaic-blueprint-theme"
/>
)}
</section>
<TabBar />
</main>
</>
);
}

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface JsonViewerProps {
data: any;
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
}
export function JsonViewer({
data,
open,
onOpenChange,
title = "Raw JSON",
}: JsonViewerProps) {
const [copied, setCopied] = useState(false);
const jsonString = JSON.stringify(data, null, 2);
const handleCopy = () => {
navigator.clipboard.writeText(jsonString);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center justify-between pr-8">
<span>{title}</span>
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="gap-2"
>
{copied ? (
<>
<Check className="w-4 h-4" />
Copied
</>
) : (
<>
<Copy className="w-4 h-4" />
Copy
</>
)}
</Button>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto mt-2">
<pre className="text-xs font-mono bg-muted p-4 rounded-lg">
{jsonString}
</pre>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,61 @@
import { getKindInfo } from "@/constants/kinds";
import { cn } from "@/lib/utils";
interface KindBadgeProps {
kind: number;
showIcon?: boolean;
showName?: boolean;
showKindNumber?: boolean;
variant?: "default" | "compact" | "full";
className?: string;
iconClassname?: string;
}
export function KindBadge({
kind,
showIcon: propShowIcon,
showName: propShowName,
showKindNumber: propShowKindNumber,
variant = "default",
className = "",
iconClassname = "text-muted-foreground",
}: KindBadgeProps) {
const kindInfo = getKindInfo(kind);
const Icon = kindInfo?.icon;
const style = "inline-flex items-center gap-2 text-foreground";
// Apply variant presets or use props
let showIcon = propShowIcon ?? true;
let showName = propShowName ?? true;
let showKindNumber = propShowKindNumber ?? false;
if (variant === "compact") {
showIcon = true;
showName = false;
showKindNumber = false;
} else if (variant === "full") {
showIcon = true;
showName = true;
showKindNumber = true;
}
if (!kindInfo) {
return (
<div className={cn(style, className)}>
<span>Kind {kind}</span>
</div>
);
}
return (
<div
className={cn(style, className)}
title={`${kindInfo.description} (NIP-${kindInfo.nip})`}
>
{showIcon && Icon && <Icon className={cn("size-4", iconClassname)} />}
{showName && <span>{kindInfo.name}</span>}
{showKindNumber && <span>({kind})</span>}
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { getKindInfo } from "@/constants/kinds";
import { KindBadge } from "./KindBadge";
import Command from "./Command";
import { ExternalLink } from "lucide-react";
export default function KindRenderer({ kind }: { kind: number }) {
const kindInfo = getKindInfo(kind);
const Icon = kindInfo?.icon;
const category = getKindCategory(kind);
const eventType = getEventType(kind);
if (!kindInfo) {
return (
<div className="h-full w-full flex items-center justify-center p-6">
<div className="text-center max-w-md">
<div className="text-lg font-semibold mb-2">Kind {kind}</div>
<p className="text-sm text-muted-foreground">
This event kind is not yet documented in Grimoire.
</p>
</div>
</div>
);
}
return (
<div className="h-full w-full overflow-y-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-start gap-4">
{Icon && (
<div className="w-12 h-12 bg-accent/20 rounded flex items-center justify-center flex-shrink-0">
<Icon className="w-6 h-6 text-accent" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="mb-2">
<KindBadge kind={kind} variant="full" />
</div>
<h1 className="text-2xl font-bold mb-1">{kindInfo.name}</h1>
<p className="text-muted-foreground">{kindInfo.description}</p>
</div>
</div>
{/* Details Grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div className="text-muted-foreground">Kind Number</div>
<code className="font-mono">{kind}</code>
<div className="text-muted-foreground">Category</div>
<div>{category}</div>
<div className="text-muted-foreground">Event Type</div>
<div>{eventType}</div>
<div className="text-muted-foreground">Storage</div>
<div>
{kind >= 20000 && kind < 30000
? "Not stored (ephemeral)"
: "Stored by relays"}
</div>
{kind >= 30000 && kind < 40000 && (
<>
<div className="text-muted-foreground">Identifier</div>
<code className="font-mono text-xs">d-tag</code>
</>
)}
{kindInfo.nip && (
<>
<div className="text-muted-foreground">Defined in</div>
<div>
<Command
name={`NIP-${kindInfo.nip}`}
description={`View NIP-${kindInfo.nip} specification`}
appId="nip"
props={{ number: kindInfo.nip }}
/>
</div>
</>
)}
</div>
{/* GitHub Link */}
{kindInfo.nip && (
<div className="pt-4 border-t border-border">
<a
href={`https://github.com/nostr-protocol/nips/blob/master/${kindInfo.nip.padStart(
2,
"0"
)}.md`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="w-4 h-4" />
View on GitHub
</a>
</div>
)}
</div>
);
}
/**
* Get the category of an event kind
*/
function getKindCategory(kind: number): string {
if (kind >= 0 && kind <= 10) return "Core Protocol";
if (kind >= 11 && kind <= 19) return "Communication";
if (kind >= 20 && kind <= 39) return "Media & Content";
if (kind >= 40 && kind <= 49) return "Channels";
if (kind >= 1000 && kind <= 9999) return "Application Specific";
if (kind >= 10000 && kind <= 19999) return "Regular Lists";
if (kind >= 20000 && kind <= 29999) return "Ephemeral Events";
if (kind >= 30000 && kind <= 39999) return "Parameterized Replaceable";
if (kind >= 40000) return "Custom/Experimental";
return "Other";
}
/**
* Determine the replaceability of an event kind
*/
function getEventType(kind: number): string {
if (kind === 0 || kind === 3 || (kind >= 10000 && kind < 20000)) {
return "Replaceable";
}
if (kind >= 30000 && kind < 40000) {
return "Parameterized Replaceable";
}
if (kind >= 20000 && kind < 30000) {
return "Ephemeral";
}
return "Regular";
}

108
src/components/ManPage.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { manPages } from "@/types/man";
interface ManPageProps {
cmd: string;
}
export default function ManPage({ cmd }: ManPageProps) {
const page = manPages[cmd];
if (!page) {
return (
<div className="p-6 font-mono text-sm">
<div className="text-destructive">No manual entry for {cmd}</div>
<div className="mt-4 text-muted-foreground">
Use 'help' to see available commands.
</div>
</div>
);
}
return (
<div className="p-6 font-mono text-sm space-y-4 max-w-4xl">
{/* Header */}
<div className="flex justify-between border-b border-border pb-2">
<span className="font-bold">
{page.name.toUpperCase()}({page.section})
</span>
<span className="text-muted-foreground">Grimoire Manual</span>
<span className="font-bold">
{page.name.toUpperCase()}({page.section})
</span>
</div>
{/* NAME */}
<section>
<h2 className="font-bold mb-2">NAME</h2>
<div className="ml-8">
{page.name} - {page.description.split(".")[0]}
</div>
</section>
{/* SYNOPSIS */}
<section>
<h2 className="font-bold mb-2">SYNOPSIS</h2>
<div className="ml-8 text-accent">{page.synopsis}</div>
</section>
{/* DESCRIPTION */}
<section>
<h2 className="font-bold mb-2">DESCRIPTION</h2>
<div className="ml-8 text-muted-foreground">{page.description}</div>
</section>
{/* OPTIONS */}
{page.options && page.options.length > 0 && (
<section>
<h2 className="font-bold mb-2">OPTIONS</h2>
<div className="ml-8 space-y-2">
{page.options.map((opt, i) => (
<div key={i} className="flex gap-4">
<span className="text-accent font-semibold min-w-[120px]">
{opt.flag}
</span>
<span className="text-muted-foreground">{opt.description}</span>
</div>
))}
</div>
</section>
)}
{/* EXAMPLES */}
{page.examples && page.examples.length > 0 && (
<section>
<h2 className="font-bold mb-2">EXAMPLES</h2>
<div className="ml-8 space-y-1">
{page.examples.map((example, i) => (
<div key={i} className="text-muted-foreground">
{example}
</div>
))}
</div>
</section>
)}
{/* SEE ALSO */}
{page.seeAlso && page.seeAlso.length > 0 && (
<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>
</div>
</section>
)}
{/* Footer */}
<div className="border-t border-border pt-2 text-muted-foreground text-xs">
Grimoire 1.0.0 {new Date().getFullYear()}
</div>
</div>
);
}

160
src/components/Markdown.tsx Normal file
View File

@@ -0,0 +1,160 @@
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import { useGrimoire } from "@/core/state";
import { MediaEmbed } from "@/components/nostr/MediaEmbed";
interface MarkdownProps {
content: string;
className?: string;
}
export function Markdown({ content, className = "" }: MarkdownProps) {
const { addWindow } = useGrimoire();
const components: Components = {
// Headings
h1: ({ children }) => (
<h1 className="text-lg font-bold mt-4 mb-3 first:mt-0">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-base font-bold mt-4 mb-2 first:mt-0">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-sm font-bold mt-3 mb-2 first:mt-0">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-sm font-bold mt-3 mb-2 first:mt-0">{children}</h4>
),
h5: ({ children }) => (
<h5 className="text-xs font-bold mt-2 mb-1 first:mt-0">{children}</h5>
),
h6: ({ children }) => (
<h6 className="text-xs font-bold mt-2 mb-1 first:mt-0">{children}</h6>
),
// Paragraphs and text
p: ({ children }) => (
<p className="mb-3 leading-relaxed text-sm last:mb-0 break-words">
{children}
</p>
),
// Links
a: ({ href, children }) => {
// Check if it's a relative NIP link (e.g., "./01.md" or "01.md")
if (href && (href.endsWith(".md") || href.includes(".md#"))) {
// Extract NIP number from various formats
const nipMatch = href.match(/(\d{2})\.md/);
if (nipMatch) {
const nipNumber = nipMatch[1];
return (
<span
onClick={(e) => {
e.preventDefault();
addWindow("nip", { number: nipNumber }, `NIP ${nipNumber}`);
}}
className="text-primary underline decoration-dotted cursor-pointer hover:text-primary/80 transition-colors"
>
{children}
</span>
);
}
}
// Regular external link
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline decoration-dotted cursor-crosshair hover:text-primary/80 transition-colors"
>
{children}
</a>
);
},
// Lists
ul: ({ children }) => (
<ul className="list-disc list-inside mb-3 space-y-1 text-sm">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside mb-3 space-y-1 text-sm">
{children}
</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-muted-foreground/30 pl-3 py-2 my-3 italic text-muted-foreground text-sm">
{children}
</blockquote>
),
// Code
code: (props) => {
const { children, className } = props;
const inline = !className?.includes("language-");
return inline ? (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{children}
</code>
) : (
<code className="block bg-muted p-3 rounded-lg my-3 overflow-x-auto text-xs leading-relaxed max-w-full">
{children}
</code>
);
},
pre: ({ children }) => <pre className="my-3">{children}</pre>,
// Horizontal rule
hr: () => <hr className="my-4 border-border" />,
// Tables
table: ({ children }) => (
<div className="overflow-x-auto my-3">
<table className="min-w-full border-collapse border border-border text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => (
<th className="px-3 py-1.5 text-left font-bold border border-border">
{children}
</th>
),
td: ({ children }) => (
<td className="px-3 py-1.5 border border-border">{children}</td>
),
// Images - Inline with zoom
img: ({ src, alt }) =>
src ? (
<MediaEmbed
url={src}
alt={alt}
preset="preview"
enableZoom
className="my-3"
/>
) : null,
// Emphasis
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
};
return (
<div className={className}>
<ReactMarkdown components={components}>{content}</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useNip } from "@/hooks/useNip";
import { Markdown } from "./Markdown";
import { KindBadge } from "./KindBadge";
import { getKindsForNip } from "@/lib/nip-kinds";
interface NipRendererProps {
nipId: string;
className?: string;
}
export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
const { content, loading, error } = useNip(nipId);
const kinds = getKindsForNip(nipId);
if (loading) {
return (
<div className={`p-4 ${className}`}>
<div className="text-muted-foreground text-sm">
Loading NIP-{nipId}...
</div>
</div>
);
}
if (error) {
return (
<div className={`p-4 ${className}`}>
<div className="text-destructive text-sm">
Error loading NIP-{nipId}: {error.message}
</div>
</div>
);
}
if (!content) {
return null;
}
return (
<div className={`p-4 overflow-x-hidden ${className}`}>
<Markdown content={content} />
{kinds.length > 0 && (
<div className="mt-6 pt-4 border-t border-border">
<h3 className="text-sm font-bold mb-3">
Event Kinds Defined in NIP-{nipId}
</h3>
<div className="flex flex-wrap gap-2">
{kinds.map((kind) => (
<KindBadge key={kind} kind={kind} variant="full" />
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { useState } from "react";
import { useProfile } from "@/hooks/useProfile";
import { UserName } from "./nostr/UserName";
import Nip05 from "./nostr/nip05";
import {
Copy,
ChevronDown,
ChevronRight,
User as UserIcon,
Circle,
Inbox,
Send,
} from "lucide-react";
import { kinds, nip19 } from "nostr-tools";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { getInboxes, getOutboxes } from "applesauce-core/helpers/mailboxes";
import { RichText } from "./nostr/RichText";
export interface ProfileViewerProps {
pubkey: string;
}
/**
* ProfileViewer - Detailed view for a user profile
* Shows profile metadata, inbox/outbox relays, and raw JSON
*/
export function ProfileViewer({ pubkey }: ProfileViewerProps) {
const profile = useProfile(pubkey);
const eventStore = useEventStore();
const [showInboxes, setShowInboxes] = useState(false);
const [showOutboxes, setShowOutboxes] = useState(false);
// Get mailbox relays (kind 10002)
const mailboxEvent = useObservableMemo(
() => eventStore.replaceable(kinds.RelayList, pubkey, ""),
[eventStore, pubkey],
);
const inboxRelays =
mailboxEvent && mailboxEvent.tags ? getInboxes(mailboxEvent) : [];
const outboxRelays =
mailboxEvent && mailboxEvent.tags ? getOutboxes(mailboxEvent) : [];
// Get profile metadata event (kind 0)
const profileEvent = useObservableMemo(
() => eventStore.replaceable(0, pubkey, ""),
[eventStore, pubkey],
);
// Helper to copy to clipboard
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
// Combine all relays (inbox + outbox) for nprofile
const allRelays = [...new Set([...inboxRelays, ...outboxRelays])];
// Generate npub or nprofile depending on relay availability
const identifier =
allRelays.length > 0
? nip19.nprofileEncode({
pubkey,
relays: allRelays,
})
: nip19.npubEncode(pubkey);
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Compact Header - Single Line */}
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
{/* Left: npub/nprofile */}
<button
onClick={() => copyToClipboard(identifier)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
title={identifier}
>
<Copy className="size-3 flex-shrink-0" />
<code className="truncate">
{identifier.slice(0, 16)}...{identifier.slice(-8)}
</code>
</button>
{/* Right: Profile icon and Relay counts */}
<div className="flex items-center gap-3 flex-shrink-0">
<div className="flex items-center gap-1 text-muted-foreground">
<UserIcon className="size-3" />
<span>Profile</span>
</div>
{allRelays.length > 0 && (
<>
{inboxRelays.length > 0 && (
<button
onClick={() => setShowInboxes(!showInboxes)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
title="Inbox relays"
>
{showInboxes ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<Inbox className="size-3" />
<span>{inboxRelays.length}</span>
</button>
)}
{outboxRelays.length > 0 && (
<button
onClick={() => setShowOutboxes(!showOutboxes)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
title="Outbox relays"
>
{showOutboxes ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<Send className="size-3" />
<span>{outboxRelays.length}</span>
</button>
)}
</>
)}
</div>
</div>
{/* Expandable Inbox Relays */}
{showInboxes && inboxRelays.length > 0 && (
<div className="border-b border-border px-4 py-2 bg-muted">
<div className="text-xs text-muted-foreground mb-2 font-semibold">
Inbox Relays
</div>
<div className="flex flex-col gap-2">
{inboxRelays.map((relay) => (
<div key={relay} className="flex items-center gap-2">
<Circle className="size-2 fill-blue-500 text-blue-500" />
<span className="text-xs font-mono text-muted-foreground">
{relay}
</span>
</div>
))}
</div>
</div>
)}
{/* Expandable Outbox Relays */}
{showOutboxes && outboxRelays.length > 0 && (
<div className="border-b border-border px-4 py-2 bg-muted">
<div className="text-xs text-muted-foreground mb-2 font-semibold">
Outbox Relays
</div>
<div className="flex flex-col gap-2">
{outboxRelays.map((relay) => (
<div key={relay} className="flex items-center gap-2">
<Circle className="size-2 fill-green-500 text-green-500" />
<span className="text-xs font-mono text-muted-foreground">
{relay}
</span>
</div>
))}
</div>
</div>
)}
{/* Profile Content */}
<div className="flex-1 overflow-y-auto p-4">
{!profile && !profileEvent && (
<div className="text-center text-muted-foreground text-sm">
Loading profile...
</div>
)}
{!profile && profileEvent && (
<div className="text-center text-muted-foreground text-sm">
No profile metadata found
</div>
)}
{profile && (
<div className="flex flex-col gap-4 max-w-2xl">
<div className="flex flex-col gap-0">
{/* Display Name */}
<UserName pubkey={pubkey} className="text-2xl font-bold" />
{/* NIP-05 */}
{profile.nip05 && (
<div className="text-xs text-muted-foreground">
<Nip05 pubkey={pubkey} profile={profile} />
</div>
)}
</div>
{/* About/Bio */}
{profile.about && (
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground uppercase tracking-wide">
About
</div>
<RichText
className="text-sm whitespace-pre-wrap break-words"
content={profile.about}
/>
</div>
)}
{/* Website */}
{profile.website && (
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground uppercase tracking-wide">
Website
</div>
<a
href={profile.website}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-accent underline decoration-dotted"
>
{profile.website}
</a>
</div>
)}
{/* Lightning Address */}
{profile.lud16 && (
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground uppercase tracking-wide">
Lightning Address
</div>
<code className="text-sm font-mono">{profile.lud16}</code>
</div>
)}
{/* LUD06 (LNURL) */}
{profile.lud06 && (
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground uppercase tracking-wide">
LNURL
</div>
<code className="text-sm font-mono break-all">
{profile.lud06}
</code>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
import { useState } from "react";
import {
ChevronDown,
ChevronRight,
Radio,
FileText,
Wifi,
Filter as FilterIcon,
Circle,
} from "lucide-react";
import { useReqTimeline } from "@/hooks/useReqTimeline";
import { useGrimoire } from "@/core/state";
import { FeedEvent } from "./nostr/Feed";
import { KindBadge } from "./KindBadge";
import type { NostrFilter } from "@/types/nostr";
interface ReqViewerProps {
filter: NostrFilter;
relays?: string[];
closeOnEose?: boolean;
nip05Authors?: string[];
nip05PTags?: string[];
}
export default function ReqViewer({
filter,
relays,
closeOnEose = false,
nip05Authors,
nip05PTags,
}: ReqViewerProps) {
const { state } = useGrimoire();
// NIP-05 resolution already happened in argParser before window creation
// The filter prop already contains resolved pubkeys
// We just display the NIP-05 identifiers for user reference
// Use inbox relays if logged in and no relays specified
const defaultRelays =
relays ||
(state.activeAccount?.relays?.inbox.length
? state.activeAccount.relays.inbox.map((r) => r.url)
: ["wss://theforest.nostr1.com"]);
// Streaming is the default behavior, closeOnEose inverts it
const stream = !closeOnEose;
const { events, loading, error, eoseReceived } = useReqTimeline(
`req-${JSON.stringify(filter)}-${closeOnEose}`,
filter,
defaultRelays,
{ limit: filter.limit || 50, stream },
);
const [showRelays, setShowRelays] = useState(false);
const [showQuery, setShowQuery] = useState(false);
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
{/* Compact Header */}
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between">
{/* Left: Status Indicator */}
<div className="flex items-center gap-2">
<Radio
className={`size-3 ${
loading && !eoseReceived
? "text-yellow-500 animate-pulse"
: loading && eoseReceived && stream
? "text-green-500 animate-pulse"
: !loading && eoseReceived
? "text-muted-foreground"
: "text-yellow-500 animate-pulse"
}`}
/>
<span
className={`${
loading && !eoseReceived
? "text-yellow-500"
: loading && eoseReceived && stream
? "text-green-500"
: !loading && eoseReceived
? "text-muted-foreground"
: "text-yellow-500"
} font-semibold`}
>
{loading && !eoseReceived
? "LOADING"
: loading && eoseReceived && stream
? "LIVE"
: !loading && eoseReceived
? "CLOSED"
: "CONNECTING"}
</span>
</div>
{/* Right: Stats */}
<div className="flex items-center gap-3">
{/* Event Count */}
<div className="flex items-center gap-1 text-muted-foreground">
<FileText className="size-3" />
<span>{events.length}</span>
</div>
{/* Relay Count (Clickable) */}
<button
onClick={() => setShowRelays(!showRelays)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showRelays ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<Wifi className="size-3" />
<span>{defaultRelays.length}</span>
</button>
{/* Query (Clickable) */}
<button
onClick={() => setShowQuery(!showQuery)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showQuery ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<FilterIcon className="size-3" />
</button>
</div>
</div>
{/* Expandable Relays */}
{showRelays && (
<div className="border-b border-border px-4 py-2 bg-muted">
<div className="flex flex-col gap-2">
{defaultRelays.map((relay) => (
<div key={relay} className="flex items-center gap-2">
<Circle className="size-2 fill-green-500 text-green-500" />
<span className="text-xs font-mono text-muted-foreground">
{relay}
</span>
</div>
))}
</div>
</div>
)}
{/* Expandable Query */}
{showQuery && (
<div className="border-b border-border px-4 py-2 bg-muted space-y-2">
{/* Kind Badges */}
{filter.kinds && filter.kinds.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Kinds:</span>
{filter.kinds.map((kind) => (
<KindBadge key={kind} kind={kind} variant="full" />
))}
</div>
)}
{/* Authors with NIP-05 info */}
{filter.authors && filter.authors.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
Authors: {filter.authors.length}
</span>
{nip05Authors && nip05Authors.length > 0 && (
<div className="text-xs text-blue-500 ml-2">
{nip05Authors.map((nip05) => (
<div key={nip05}> {nip05}</div>
))}
</div>
)}
</div>
)}
{/* #p Tags with NIP-05 info */}
{filter["#p"] && filter["#p"].length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
#p Tags: {filter["#p"].length}
</span>
{nip05PTags && nip05PTags.length > 0 && (
<div className="text-xs text-blue-500 ml-2">
{nip05PTags.map((nip05) => (
<div key={nip05}> {nip05}</div>
))}
</div>
)}
</div>
)}
{/* Limit */}
{filter.limit && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Limit: {filter.limit}
</span>
</div>
)}
{/* Stream Mode */}
{stream && (
<div className="flex items-center gap-2">
<span className="text-xs text-green-500">
Streaming mode enabled
</span>
</div>
)}
{/* Raw Query */}
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Query Filter
</summary>
<pre className="mt-2 text-xs font-mono text-muted-foreground bg-background p-2 overflow-x-auto">
{JSON.stringify(filter, null, 2)}
</pre>
</details>
</div>
)}
{/* Error Display */}
{error && (
<div className="border-b border-border px-4 py-2 bg-destructive/10">
<span className="text-xs font-mono text-destructive">
Error: {error.message}
</span>
</div>
)}
{/* Results */}
<div className="flex-1 overflow-y-auto">
{loading && events.length === 0 && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Loading events...
</div>
)}
{!loading && !stream && events.length === 0 && !error && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
No events found matching filter
</div>
)}
{stream && events.length === 0 && !loading && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Waiting for events...
</div>
)}
{events.map((event) => (
<FeedEvent key={event.id} event={event} />
))}
</div>
</div>
);
}

40
src/components/TabBar.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Plus } from "lucide-react";
import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
export function TabBar() {
const { state, setActiveWorkspace, createWorkspace } = useGrimoire();
const { workspaces, activeWorkspaceId } = state;
const handleNewTab = () => {
createWorkspace();
};
return (
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1">
{Object.values(workspaces).map((ws) => (
<button
key={ws.id}
onClick={() => setActiveWorkspace(ws.id)}
className={cn(
"px-3 py-1 text-xs font-mono rounded transition-colors",
ws.id === activeWorkspaceId
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{ws.label}
</button>
))}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-1"
onClick={handleNewTab}
>
<Plus className="h-3 w-3" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { useMemo } from "react";
export default function Timestamp({ timestamp }: { timestamp: number }) {
const formatted = useMemo(() => {
const intl = new Intl.DateTimeFormat("es", {
timeStyle: "short",
});
return intl.format(timestamp * 1000);
}, [timestamp]);
return formatted;
}

View File

@@ -0,0 +1,99 @@
import { useGrimoire } from "@/core/state";
import { MosaicNode } from "react-mosaic-component";
function renderTree(
node: MosaicNode<string> | null,
windows: Record<string, any>,
prefix: string = "",
isLast: boolean = true,
): string[] {
if (!node) {
return [`${prefix}(empty)`];
}
if (typeof node === "string") {
// Leaf node - window ID
const window = windows[node];
const title = window?.title || "Unknown";
const appId = window?.appId || "?";
return [`${prefix}${isLast ? "└─" : "├─"} ${node} [${appId}] "${title}"`];
}
// Branch node
const lines: string[] = [];
const connector = isLast ? "└─" : "├─";
const continuer = isLast ? " " : "│ ";
lines.push(
`${prefix}${connector} ${node.direction === "row" ? "⇆ row" : "⇅ column"} (${node.splitPercentage || 50}%)`,
);
// Render first child
const firstLines = renderTree(node.first, windows, prefix + continuer, false);
lines.push(...firstLines);
// Render second child
const secondLines = renderTree(
node.second,
windows,
prefix + continuer,
true,
);
lines.push(...secondLines);
return lines;
}
export function WinViewer() {
const { state } = useGrimoire();
const workspaceIds = Object.keys(state.workspaces);
const lines: string[] = [];
lines.push("grimoire state tree");
lines.push("");
// Global windows section
const windowCount = Object.keys(state.windows).length;
lines.push(`📦 global windows: ${windowCount}`);
Object.values(state.windows).forEach((win) => {
lines.push(` ├─ ${win.id} [${win.appId}] "${win.title}"`);
});
lines.push("");
// Workspaces section
workspaceIds.forEach((wsId, index) => {
const ws = state.workspaces[wsId];
const isActive = wsId === state.activeWorkspaceId;
const isLast = index === workspaceIds.length - 1;
const prefix = isLast ? "└─" : "├─";
const continuer = isLast ? " " : "│ ";
// Workspace header
lines.push(
`${prefix} ${isActive ? "●" : "○"} workspace: "${ws.label}" (${wsId})`,
);
// Window IDs
lines.push(`${continuer}├─ windowIds: [${ws.windowIds.join(", ")}]`);
// Layout tree
lines.push(`${continuer}└─ layout:`);
const treeLines = renderTree(
ws.layout,
state.windows,
`${continuer} `,
true,
);
lines.push(...treeLines);
if (!isLast) {
lines.push("│");
}
});
return (
<div className="p-4 h-full overflow-auto">
<pre className="text-xs leading-relaxed">{lines.join("\n")}</pre>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { X } from "lucide-react";
import { Button } from "./ui/button";
interface WindowToolbarProps {
onClose?: () => void;
}
export function WindowToolbar({ onClose }: WindowToolbarProps) {
return (
<div className="flex items-center gap-1">
{onClose && (
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={onClose}
>
<X className="size-3" />
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,227 @@
/* Command Launcher Styles - Terminal Aesthetic */
.grimoire-command-launcher {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 20vh;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.command-launcher-wrapper {
width: 100%;
max-width: 640px;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: slideDown 0.2s ease;
}
@keyframes slideDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.command-input {
width: 100%;
padding: 16px 20px;
background: transparent;
border: none;
border-bottom: 1px solid hsl(var(--border));
font-family: monospace;
font-size: 16px;
color: hsl(var(--foreground));
outline: none;
}
.command-input::placeholder {
color: hsl(var(--muted-foreground));
font-family: monospace;
}
.command-hint {
padding: 8px 20px;
border-bottom: 1px solid hsl(var(--border));
font-family: monospace;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
background: hsl(var(--muted));
}
.command-hint-label {
color: hsl(var(--muted-foreground));
}
.command-hint-command {
color: hsl(var(--foreground));
font-weight: 600;
}
.command-hint-args {
color: hsl(var(--accent));
}
.command-list {
max-height: 400px;
overflow-y: auto;
padding: 8px;
}
.command-empty {
padding: 32px 20px;
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 14px;
font-family: monospace;
}
.command-group {
margin-bottom: 12px;
}
.command-group [cmdk-group-heading] {
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 600;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: monospace;
}
.command-item {
padding: 10px 12px;
cursor: pointer;
transition: all 0.1s ease;
margin-bottom: 2px;
border: 1px solid transparent;
}
/* Inverted selection - terminal style */
.command-item[aria-selected="true"] {
background: hsl(var(--foreground));
color: hsl(var(--background));
border-color: hsl(var(--foreground));
}
.command-item[aria-selected="false"]:hover {
background: hsl(var(--muted));
border-color: hsl(var(--border));
}
/* Exact match indicator */
.command-item[data-exact-match="true"] {
border-color: hsl(var(--accent));
}
.command-item-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.command-item-name {
display: flex;
align-items: baseline;
gap: 8px;
font-family: monospace;
font-size: 14px;
}
.command-name {
font-weight: 600;
}
.command-args {
color: hsl(var(--muted-foreground));
font-size: 12px;
}
.command-item[aria-selected="true"] .command-args {
color: hsl(var(--background));
opacity: 0.7;
}
.command-match-indicator {
margin-left: auto;
color: hsl(var(--accent));
font-size: 12px;
}
.command-item[aria-selected="true"] .command-match-indicator {
color: hsl(var(--background));
}
.command-item-description {
font-size: 12px;
color: hsl(var(--muted-foreground));
font-family: monospace;
}
.command-item[aria-selected="true"] .command-item-description {
color: hsl(var(--background));
opacity: 0.8;
}
.command-footer {
padding: 12px 20px;
border-top: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: hsl(var(--muted-foreground));
font-family: monospace;
}
.command-footer-status {
color: hsl(var(--accent));
font-weight: 600;
}
.command-footer kbd {
padding: 2px 6px;
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
font-size: 11px;
font-family: monospace;
margin: 0 4px;
}
/* Scrollbar styling to match terminal aesthetic */
.command-list::-webkit-scrollbar {
width: 8px;
}
.command-list::-webkit-scrollbar-track {
background: transparent;
}
.command-list::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
}
.command-list::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.3);
}

View File

@@ -0,0 +1,79 @@
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./kinds";
interface EmbeddedEventProps {
/** Event ID string for regular events */
eventId?: string;
/** AddressPointer for addressable/replaceable events */
addressPointer?: { kind: number; pubkey: string; identifier: string };
/** Callback when user clicks to open the event in new window */
onOpen?: (
id: string | { kind: number; pubkey: string; identifier: string },
) => void;
/** Optional loading fallback */
loadingFallback?: React.ReactNode;
/** Optional className for container */
className?: string;
}
/**
* Reusable component for embedding Nostr events
* Handles loading state and displays the embedded event using KindRenderer
*/
export function EmbeddedEvent({
eventId,
addressPointer,
onOpen,
loadingFallback,
className = "my-4 border border-muted rounded overflow-hidden",
}: EmbeddedEventProps) {
// Determine pointer to use
const pointer = eventId || addressPointer;
// Load the event
const event = useNostrEvent(pointer);
// If event loaded, render it
if (event) {
return (
<div className={className}>
<KindRenderer event={event} />
</div>
);
}
// If loading and we have a fallback, show it
if (loadingFallback) {
return <>{loadingFallback}</>;
}
// Default loading state - show clickable link if onOpen provided
if (onOpen && pointer) {
const displayText =
typeof eventId === "string"
? `@${eventId.slice(0, 8)}...`
: addressPointer
? `@${addressPointer.identifier || addressPointer.kind}`
: "@event";
return (
<a
href="#"
onClick={(e) => {
e.preventDefault();
onOpen(pointer);
}}
className="inline-flex items-center gap-1 text-accent underline decoration-dotted break-all"
>
<span>{displayText}</span>
</a>
);
}
// No onOpen handler - just show loading text
return (
<span className="text-sm text-muted-foreground italic">
Loading event...
</span>
);
}

View File

@@ -0,0 +1,40 @@
import { useTimeline } from "@/hooks/useTimeline";
import { kinds } from "nostr-tools";
import { NostrEvent } from "@/types/nostr";
import { KindRenderer } from "./kinds";
interface FeedEventProps {
event: NostrEvent;
}
/**
* FeedEvent - Renders a single event using the appropriate kind renderer
*/
export function FeedEvent({ event }: FeedEventProps) {
return <KindRenderer event={event} />;
}
/**
* Feed - Main feed component displaying timeline of events
*/
export default function Feed({ className }: { className?: string }) {
const relays = ["wss://theforest.nostr1.com"];
const { events } = useTimeline(
"feed-forest",
{
kinds: [kinds.ShortTextNote],
},
relays,
{
limit: 200,
},
);
return (
<div className={className}>
{events.map((e) => (
<FeedEvent key={e.id} event={e} />
))}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Music } from "lucide-react";
interface AudioLinkProps {
url: string;
onClick: () => void;
}
export function AudioLink({ url, onClick }: AudioLinkProps) {
return (
<button
onClick={onClick}
className="inline-flex items-baseline gap-1 text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair break-all line-clamp-1"
>
<Music className="h-3 w-3 flex-shrink-0" />
<span>{url}</span>
</button>
);
}

View File

@@ -0,0 +1,18 @@
import { Image } from "lucide-react";
interface ImageLinkProps {
url: string;
onClick: () => void;
}
export function ImageLink({ url, onClick }: ImageLinkProps) {
return (
<button
onClick={onClick}
className="inline-flex items-baseline gap-1 text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair break-all line-clamp-1"
>
<Image className="h-3 w-3 flex-shrink-0" />
<span>{url}</span>
</button>
);
}

View File

@@ -0,0 +1,19 @@
import { ExternalLink } from "lucide-react";
interface PlainLinkProps {
url: string;
}
export function PlainLink({ url }: PlainLinkProps) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-baseline gap-1 text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair break-all"
>
<ExternalLink className="h-3 w-3 flex-shrink-0" />
<span>{url}</span>
</a>
);
}

View File

@@ -0,0 +1,18 @@
import { Video } from "lucide-react";
interface VideoLinkProps {
url: string;
onClick: () => void;
}
export function VideoLink({ url, onClick }: VideoLinkProps) {
return (
<button
onClick={onClick}
className="inline-flex items-baseline gap-1 text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair break-all line-clamp-1"
>
<Video className="h-3 w-3 flex-shrink-0" />
<span>{url}</span>
</button>
);
}

View File

@@ -0,0 +1,4 @@
export { ImageLink } from "./ImageLink";
export { VideoLink } from "./VideoLink";
export { AudioLink } from "./AudioLink";
export { PlainLink } from "./PlainLink";

View File

@@ -0,0 +1,128 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
isImageURL,
isVideoURL,
isAudioURL,
} from "applesauce-core/helpers/url";
interface MediaDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
urls: string[];
initialIndex?: number;
}
/**
* Dialog for viewing media (images, videos, audio)
* Supports gallery navigation when multiple URLs provided
*/
export function MediaDialog({
open,
onOpenChange,
urls,
initialIndex = 0,
}: MediaDialogProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const currentUrl = urls[currentIndex];
const isGallery = urls.length > 1;
const handlePrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : urls.length - 1));
};
const handleNext = () => {
setCurrentIndex((prev) => (prev < urls.length - 1 ? prev + 1 : 0));
};
const renderMedia = (url: string) => {
if (isImageURL(url)) {
return (
<img
src={url}
alt="Media content"
className="max-w-full max-h-[80vh] object-contain mx-auto"
/>
);
}
if (isVideoURL(url)) {
return (
<video
src={url}
controls
className="max-w-full max-h-[80vh] mx-auto"
autoPlay
/>
);
}
if (isAudioURL(url)) {
return (
<div className="flex flex-col items-center gap-4 p-8">
<audio src={url} controls autoPlay className="w-full max-w-md" />
<p className="text-sm text-muted-foreground break-all">{url}</p>
</div>
);
}
return (
<div className="p-8 text-center">
<p className="text-sm text-muted-foreground">
Unable to preview this media type
</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline text-sm mt-2 inline-block"
>
Open in new tab
</a>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl">
<DialogHeader>
<DialogTitle>
{isGallery
? `Media ${currentIndex + 1} of ${urls.length}`
: "Media"}
</DialogTitle>
</DialogHeader>
<div className="relative">
{renderMedia(currentUrl)}
{isGallery && (
<>
<button
onClick={handlePrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 bg-background/80 hover:bg-background border border-border rounded-sm p-2"
aria-label="Previous media"
>
<ChevronLeft className="h-6 w-6" />
</button>
<button
onClick={handleNext}
className="absolute right-4 top-1/2 -translate-y-1/2 bg-background/80 hover:bg-background border border-border rounded-sm p-2"
aria-label="Next media"
>
<ChevronRight className="h-6 w-6" />
</button>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,199 @@
import { useState } from "react";
import Zoom from "react-medium-image-zoom";
import "react-medium-image-zoom/dist/styles.css";
import { Music, AlertCircle } from "lucide-react";
import {
isImageURL,
isVideoURL,
isAudioURL,
} from "applesauce-core/helpers/url";
import { cn } from "@/lib/utils";
interface MediaEmbedProps {
url: string;
type?: "image" | "video" | "audio" | "auto";
alt?: string;
preset?: "inline" | "thumbnail" | "preview" | "banner";
className?: string;
// Image-specific
enableZoom?: boolean; // default: true for images
// Video/Audio-specific
showControls?: boolean; // default: true
onAudioClick?: () => void; // open dialog for audio
}
const PRESETS = {
inline: {
maxHeight: "300px",
maxWidth: "100%",
rounded: "rounded-lg",
},
thumbnail: {
maxWidth: "120px",
maxHeight: "120px",
rounded: "rounded-md",
},
preview: {
maxHeight: "500px",
maxWidth: "100%",
rounded: "rounded-lg",
},
banner: {
maxHeight: "200px",
maxWidth: "100%",
rounded: "rounded-xl",
},
} as const;
/**
* MediaEmbed component for displaying images, videos, and audio with constraints
* - Images: Use react-medium-image-zoom for inline zoom
* - Videos: Show preview with play button, can trigger dialog
* - Audio: Show audio player
*/
export function MediaEmbed({
url,
type = "auto",
alt,
preset = "inline",
className = "",
enableZoom = true,
showControls = true,
onAudioClick,
}: MediaEmbedProps) {
const [error, setError] = useState(false);
// Auto-detect media type if not specified
const mediaType =
type === "auto"
? isImageURL(url)
? "image"
: isVideoURL(url)
? "video"
: isAudioURL(url)
? "audio"
: "unknown"
: type;
const presetStyles = PRESETS[preset];
const handleError = () => {
setError(true);
};
// Error fallback UI
if (error) {
return (
<div
className={cn(
"flex flex-col items-center gap-2 p-4 border border-destructive/50 rounded-lg bg-destructive/10",
className,
)}
>
<AlertCircle className="w-6 h-6 text-destructive" />
<p className="text-sm text-destructive">Failed to load media</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary underline hover:text-primary/80"
>
Open in new tab
</a>
</div>
);
}
// Image rendering with zoom
if (mediaType === "image") {
const imageElement = (
<img
src={url}
alt={alt || "Image"}
loading="lazy"
className={cn(
"w-full h-auto object-contain",
presetStyles.rounded,
enableZoom && "cursor-zoom-in",
className,
)}
style={{
maxHeight: presetStyles.maxHeight,
maxWidth: preset === "thumbnail" ? presetStyles.maxWidth : "100%",
}}
onError={handleError}
/>
);
return enableZoom ? (
<Zoom zoomMargin={40}>{imageElement}</Zoom>
) : (
imageElement
);
}
// Video rendering with inline playback
if (mediaType === "video") {
return (
<video
src={url}
className={cn("w-full", presetStyles.rounded, className)}
style={{ maxHeight: presetStyles.maxHeight }}
preload="metadata"
controls={showControls}
onError={handleError}
/>
);
}
// Audio rendering
if (mediaType === "audio") {
return (
<div
className={cn(
"flex items-center gap-3 p-3 border border-border rounded-lg bg-muted/20",
onAudioClick && "cursor-pointer hover:bg-muted/30 transition-colors",
className,
)}
onClick={onAudioClick}
>
<Music className="w-4 h-4 text-muted-foreground flex-shrink-0" />
{!onAudioClick ? (
<audio
src={url}
controls={showControls}
className="flex-1 h-8"
controlsList="nodownload"
onError={handleError}
/>
) : (
<span className="flex-1 text-sm text-muted-foreground truncate">
{url}
</span>
)}
</div>
);
}
// Unknown media type fallback
return (
<div
className={cn(
"flex flex-col gap-2 p-3 border border-border rounded-lg bg-muted/20",
className,
)}
>
<p className="text-sm text-muted-foreground">Unsupported media type</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary underline hover:text-primary/80 break-all"
>
{url}
</a>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import type { NostrEvent } from "@/types/nostr";
import { useRenderedContent } from "applesauce-react/hooks";
import { cn } from "@/lib/utils";
import { Text } from "./RichText/Text";
import { Hashtag } from "./RichText/Hashtag";
import { Mention } from "./RichText/Mention";
import { Link } from "./RichText/Link";
import { Emoji } from "./RichText/Emoji";
import { Gallery } from "./RichText/Gallery";
interface RichTextProps {
event?: NostrEvent;
content?: string;
className?: string;
}
// Content node component types for rendering
const contentComponents = {
text: Text,
hashtag: Hashtag,
mention: Mention,
link: Link,
emoji: Emoji,
gallery: Gallery,
};
/**
* RichText component that renders Nostr event content with rich formatting
* Supports mentions, hashtags, links, emojis, and galleries
* Can also render plain text without requiring a full event
*/
export function RichText({ event, content, className = "" }: RichTextProps) {
// If plain content is provided, just render it
if (content && !event) {
return (
<span
className={cn(
"whitespace-pre-line leading-tight break-words",
className,
)}
>
{content.trim()}
</span>
);
}
// Render event content with rich formatting
if (event) {
const trimmedEvent = {
...event,
content: event.content.trim(),
};
const renderedContent = useRenderedContent(trimmedEvent, contentComponents);
return (
<span
className={cn(
"whitespace-pre-line leading-tight break-words",
className,
)}
>
{renderedContent}
</span>
);
}
return null;
}

View File

@@ -0,0 +1,17 @@
interface EmojiNodeProps {
node: {
url: string;
code: string;
};
}
export function Emoji({ node }: EmojiNodeProps) {
return (
<img
src={node.url}
alt={`:${node.code}:`}
title={`:${node.code}:`}
className="inline-block size-5"
/>
);
}

View File

@@ -0,0 +1,58 @@
import { useState } from "react";
import { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { Plus, Minus } from "lucide-react";
import { EmbeddedEvent } from "../EmbeddedEvent";
interface EventEmbedNodeProps {
node: {
pointer: EventPointer | AddressPointer;
};
}
function isEventPointer(
pointer: EventPointer | AddressPointer,
): pointer is EventPointer {
return "id" in pointer;
}
export function EventEmbed({ node }: EventEmbedNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { pointer } = node;
// Determine the type label and short identifier
const isEvent = isEventPointer(pointer);
const label = isEvent ? "nevent" : "naddr";
const identifier = isEvent
? pointer.id.slice(0, 8)
: pointer.identifier || pointer.pubkey.slice(0, 8);
return (
<div className="flex flex-col w-full">
<button onClick={() => setIsExpanded(!isExpanded)}>
<div
className="flex flex-row items-center gap-1 w-full
text-muted-foreground hover:text-foreground
cursor-crosshair"
>
{isExpanded ? (
<Minus className="h-3 w-3" />
) : (
<Plus className="h-3 w-3" />
)}
<span className="">
[{label}: {identifier}...]
</span>
</div>
</button>
{isExpanded && (
<EmbeddedEvent
eventId={"id" in pointer ? pointer.id : undefined}
addressPointer={
"kind" in pointer && "pubkey" in pointer ? pointer : undefined
}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { useState } from "react";
import {
isImageURL,
isVideoURL,
isAudioURL,
} from "applesauce-core/helpers/url";
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
import { PlainLink } from "../LinkPreview";
interface GalleryNodeProps {
node: {
links?: string[];
};
}
export function Gallery({ node }: GalleryNodeProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [initialIndex, setInitialIndex] = useState(0);
const links = node.links || [];
const handleAudioClick = (index: number) => {
setInitialIndex(index);
setDialogOpen(true);
};
const renderLink = (url: string, index: number) => {
if (isImageURL(url)) {
return <MediaEmbed url={url} type="image" preset="inline" enableZoom />;
}
if (isVideoURL(url)) {
return <MediaEmbed url={url} type="video" preset="inline" />;
}
if (isAudioURL(url)) {
return (
<MediaEmbed
url={url}
type="audio"
onAudioClick={() => handleAudioClick(index)}
/>
);
}
return <PlainLink url={url} />;
};
// Only show dialog for audio files
const audioLinks = links.filter((url) => isAudioURL(url));
return (
<>
<div className="my-2 flex flex-wrap gap-2">
{links.map((url: string, i: number) => (
<div key={i}>{renderLink(url, i)}</div>
))}
</div>
{audioLinks.length > 0 && (
<MediaDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
urls={audioLinks}
initialIndex={initialIndex}
/>
)}
</>
);
}

View File

@@ -0,0 +1,17 @@
interface HashtagNodeProps {
node: {
hashtag: string;
};
}
export function Hashtag({ node }: HashtagNodeProps) {
return (
<a
href={`/t/${node.hashtag}`}
className="text-muted-foreground hover:text-primary cursor-crosshair"
onClick={(e) => e.preventDefault()}
>
#{node.hashtag}
</a>
);
}

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import {
isImageURL,
isVideoURL,
isAudioURL,
} from "applesauce-core/helpers/url";
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
import { PlainLink } from "../LinkPreview";
interface LinkNodeProps {
node: {
href: string;
};
}
export function Link({ node }: LinkNodeProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const { href } = node;
const handleAudioClick = () => {
setDialogOpen(true);
};
// Render appropriate link type
if (isImageURL(href)) {
return (
<MediaEmbed
url={href}
type="image"
preset="inline"
enableZoom
className="inline-block"
/>
);
}
if (isVideoURL(href)) {
return (
<MediaEmbed
url={href}
type="video"
preset="inline"
className="inline-block"
/>
);
}
if (isAudioURL(href)) {
return (
<>
<MediaEmbed
url={href}
type="audio"
onAudioClick={handleAudioClick}
className="inline-block"
/>
<MediaDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
urls={[href]}
initialIndex={0}
/>
</>
);
}
// Plain link for non-media URLs
return <PlainLink url={href} />;
}

View File

@@ -0,0 +1,60 @@
import { kinds } from "nostr-tools";
import { UserName } from "../UserName";
import { EventEmbed } from "./EventEmbed";
import { EventPointer, AddressPointer } from "nostr-tools/nip19";
interface MentionNodeProps {
node: {
decoded?: {
type?: string;
data?: any;
};
encoded?: string;
};
}
export function Mention({ node }: MentionNodeProps) {
if (node.decoded?.type === "npub") {
const pubkey = node.decoded.data;
return (
<UserName
isMention
pubkey={pubkey}
className="text-muted-foreground hover:text-primary"
/>
);
}
if (node.decoded?.type === "nprofile") {
const pubkey = node.decoded.data.pubkey;
return (
<UserName
isMention
pubkey={pubkey}
className="text-muted-foreground hover:text-primary"
/>
);
}
if (node.decoded?.type === "note") {
// note is just an event ID, create a simple EventPointer
const pointer: EventPointer = {
id: node.decoded.data,
kind: kinds.ShortTextNote,
relays: [],
};
return <EventEmbed node={{ pointer }} />;
}
if (node.decoded?.type === "nevent") {
const pointer: EventPointer = node.decoded.data;
return <EventEmbed node={{ pointer }} />;
}
if (node.decoded?.type === "naddr") {
const pointer: AddressPointer = node.decoded.data;
return <EventEmbed node={{ pointer }} />;
}
return null;
}

View File

@@ -0,0 +1,9 @@
interface TextNodeProps {
node: {
value: string;
};
}
export function Text({ node }: TextNodeProps) {
return <>{node.value}</>;
}

View File

@@ -0,0 +1,6 @@
export { Text } from "./Text";
export { Hashtag } from "./Hashtag";
export { Mention } from "./Mention";
export { Link } from "./Link";
export { Emoji } from "./Emoji";
export { Gallery } from "./Gallery";

View File

@@ -0,0 +1,35 @@
import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-utils";
import { cn } from "@/lib/utils";
import { useGrimoire } from "@/core/state";
interface UserNameProps {
pubkey: string;
isMention?: boolean;
className?: string;
}
/**
* Component that displays a user's name from their Nostr profile
* Shows placeholder derived from pubkey while loading or if no profile exists
* Clicking opens the user's profile
*/
export function UserName({ pubkey, isMention, className }: UserNameProps) {
const { addWindow } = useGrimoire();
const profile = useProfile(pubkey);
const displayName = getDisplayName(pubkey, profile);
const handleClick = () => {
addWindow("profile", { pubkey }, `Profile ${pubkey.slice(0, 8)}...`);
};
return (
<span
className={cn("cursor-pointer hover:underline", className)}
onClick={handleClick}
>
{isMention ? "@" : null}
{displayName}
</span>
);
}

View File

@@ -0,0 +1,3 @@
// Export all Nostr-specific components
export { UserName } from "./UserName";
export { RichText } from "./RichText";

View File

@@ -0,0 +1,161 @@
import { useState } from "react";
import { NostrEvent } from "@/types/nostr";
import { UserName } from "../UserName";
import { KindBadge } from "@/components/KindBadge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Menu, Copy, FileJson, ExternalLink } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { JsonViewer } from "@/components/JsonViewer";
/**
* Universal event properties and utilities shared across all kind renderers
*/
export interface BaseEventProps {
event: NostrEvent;
showTimestamp?: boolean;
}
/**
* User component - displays author info with profile
*/
export function EventAuthor({ pubkey }: { pubkey: string }) {
return (
<div className="flex flex-col gap-0">
<UserName
pubkey={pubkey}
className="text-md cursor-crosshair font-semibold hover:underline hover:decoration-dotted"
/>
</div>
);
}
/**
* Event menu - universal actions for any event
*/
export function EventMenu({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
const openEventDetail = () => {
// For replaceable/parameterized replaceable events, use AddressPointer
// Replaceable: 10000-19999, Parameterized: 30000-39999
const isAddressable =
(event.kind >= 10000 && event.kind < 20000) ||
(event.kind >= 30000 && event.kind < 40000);
let pointer;
if (isAddressable) {
// Find d-tag for identifier
const dTag = event.tags.find((t) => t[0] === "d")?.[1] || "";
pointer = {
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
};
} else {
// For regular events, use EventPointer
pointer = {
id: event.id,
};
}
addWindow("open", { pointer }, `Event ${event.id.slice(0, 8)}...`);
};
const copyEventId = () => {
navigator.clipboard.writeText(event.id);
};
const viewEventJson = () => {
setJsonDialogOpen(true);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="hover:text-foreground text-muted-foreground transition-colors">
<Menu className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-row items-center gap-4">
<KindBadge kind={event.kind} variant="compact" />
<KindBadge
kind={event.kind}
showName
showKindNumber
showIcon={false}
/>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openEventDetail}>
<ExternalLink className="size-4 mr-2" />
Open
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={copyEventId}>
<Copy className="size-4 mr-2" />
Copy ID
</DropdownMenuItem>
<DropdownMenuItem onClick={viewEventJson}>
<FileJson className="size-4 mr-2" />
View JSON
</DropdownMenuItem>
</DropdownMenuContent>
<JsonViewer
data={event}
open={jsonDialogOpen}
onOpenChange={setJsonDialogOpen}
title={`Event ${event.id.slice(0, 8)}... - Raw JSON`}
/>
</DropdownMenu>
);
}
/**
* Base event container with universal header
* Kind-specific renderers can wrap their content with this
*/
export function BaseEventContainer({
event,
children,
showTimestamp = false,
}: {
event: NostrEvent;
children: React.ReactNode;
showTimestamp?: boolean;
}) {
// Format timestamp
const timestamp = new Date(event.created_at * 1000).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<div className="flex flex-col gap-1 p-2">
<div className="flex flex-row justify-between items-center">
<EventAuthor pubkey={event.pubkey} />
{showTimestamp ? (
<span className="text-xs text-muted-foreground font-mono">
{timestamp}
</span>
) : (
<EventMenu event={event} />
)}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { useState } from "react";
import { KindBadge } from "@/components/KindBadge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Menu, Copy, FileJson, ExternalLink } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { JsonViewer } from "@/components/JsonViewer";
import { NostrEvent } from "@/types/nostr";
import { UserName } from "@/components/nostr/UserName";
/**
* Universal event properties and utilities shared across all kind renderers
*/
export interface BaseEventProps {
event: NostrEvent;
showTimestamp?: boolean;
}
/**
* User component - displays author info with profile
*/
export function EventAuthor({ pubkey }: { pubkey: string }) {
return (
<div className="flex flex-col gap-0">
<UserName
pubkey={pubkey}
className="text-md cursor-crosshair font-semibold hover:underline hover:decoration-dotted"
/>
</div>
);
}
/**
* Event menu - universal actions for any event
*/
export function EventMenu({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
const openEventDetail = () => {
// For replaceable/parameterized replaceable events, use AddressPointer
// Replaceable: 10000-19999, Parameterized: 30000-39999
const isAddressable =
(event.kind >= 10000 && event.kind < 20000) ||
(event.kind >= 30000 && event.kind < 40000);
let pointer;
if (isAddressable) {
// Find d-tag for identifier
const dTag = event.tags.find((t) => t[0] === "d")?.[1] || "";
pointer = {
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
};
} else {
// For regular events, use EventPointer
pointer = {
id: event.id,
};
}
addWindow("open", { pointer }, `Event ${event.id.slice(0, 8)}...`);
};
const copyEventId = () => {
navigator.clipboard.writeText(event.id);
};
const viewEventJson = () => {
setJsonDialogOpen(true);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="hover:text-foreground text-muted-foreground transition-colors">
<Menu className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-row items-center gap-4">
<KindBadge kind={event.kind} variant="compact" />
<KindBadge
kind={event.kind}
showName
showKindNumber
showIcon={false}
/>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openEventDetail}>
<ExternalLink className="size-4 mr-2" />
Open
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={copyEventId}>
<Copy className="size-4 mr-2" />
Copy ID
</DropdownMenuItem>
<DropdownMenuItem onClick={viewEventJson}>
<FileJson className="size-4 mr-2" />
View JSON
</DropdownMenuItem>
</DropdownMenuContent>
<JsonViewer
data={event}
open={jsonDialogOpen}
onOpenChange={setJsonDialogOpen}
title={`Event ${event.id.slice(0, 8)}... - Raw JSON`}
/>
</DropdownMenu>
);
}
/**
* Base event container with universal header
* Kind-specific renderers can wrap their content with this
*/
export function BaseEventContainer({
event,
children,
showTimestamp = false,
}: {
event: NostrEvent;
children: React.ReactNode;
showTimestamp?: boolean;
}) {
// Format timestamp
const timestamp = new Date(event.created_at * 1000).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<div className="flex flex-col gap-1 p-2">
<div className="flex flex-row justify-between items-center">
<EventAuthor pubkey={event.pubkey} />
{showTimestamp ? (
<span className="text-xs text-muted-foreground font-mono">
{timestamp}
</span>
) : (
<EventMenu event={event} />
)}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ProfileViewer } from "@/components/ProfileViewer";
import type { NostrEvent } from "@/types/nostr";
/**
* Detail renderer for Kind 0 - Profile Metadata
* Uses the ProfileViewer component to show full profile view
*/
export function Kind0DetailRenderer({ event }: { event: NostrEvent }) {
return <ProfileViewer pubkey={event.pubkey} />;
}

View File

@@ -0,0 +1,62 @@
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { useProfile } from "@/hooks/useProfile";
import { UserName } from "../UserName";
import Nip05 from "../nip05";
import { RichText } from "../RichText";
/**
* Renderer for Kind 0 - Profile Metadata
* Displays as a compact profile card in feed view
*/
export function Kind0Renderer({ event, showTimestamp }: BaseEventProps) {
const pubkey = event.pubkey;
const profile = useProfile(pubkey);
const about = profile?.about;
const website = profile?.website;
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-3">
{/* Profile Info */}
<div className="flex flex-col gap-2 p-3 border border-muted bg-muted/20">
<div className="flex flex-col gap-0">
{/* Name */}
<div className="flex items-center gap-2">
<UserName
pubkey={event.pubkey}
className="text-lg font-semibold text-foreground"
/>
</div>
{/* NIP-05 */}
{profile?.nip05 && (
<span className="text-xs text-muted-foreground">
<Nip05 profile={profile} pubkey={pubkey} />
</span>
)}
</div>
{/* About */}
{about && (
<p className="text-sm text-muted-foreground line-clamp-5">
<RichText content={about} />
</p>
)}
{/* Website */}
{website && (
<a
href={website}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:underline break-all"
>
{website}
</a>
)}
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,13 @@
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import { RichText } from "../RichText";
/**
* Renderer for Kind 1 - Short Text Note
*/
export function Kind1Renderer({ event, showTimestamp }: BaseEventProps) {
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<RichText event={event} className="text-sm" />
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,247 @@
import { useMemo } from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import { remarkNostrMentions } from "applesauce-content/markdown";
import { nip19 } from "nostr-tools";
import {
getArticleTitle,
getArticleSummary,
getArticlePublished,
} from "applesauce-core/helpers/article";
import { UserName } from "../UserName";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { useGrimoire } from "@/core/state";
import type { NostrEvent } from "@/types/nostr";
/**
* Component to render nostr: mentions inline
*/
function NostrMention({ href }: { href: string }) {
const { addWindow } = useGrimoire();
try {
// Remove nostr: prefix and any trailing characters
const cleanHref = href.replace(/^nostr:/, "").trim();
// If it doesn't look like a nostr identifier, just return the href as-is
if (!cleanHref.match(/^(npub|nprofile|note|nevent|naddr)/)) {
return (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
>
{href}
</a>
);
}
const parsed = nip19.decode(cleanHref);
switch (parsed.type) {
case "npub":
return (
<span className="inline-flex items-center">
<UserName
pubkey={parsed.data}
className="text-accent font-semibold"
/>
</span>
);
case "nprofile":
return (
<span className="inline-flex items-center">
<UserName
pubkey={parsed.data.pubkey}
className="text-accent font-semibold"
/>
</span>
);
case "note":
return (
<EmbeddedEvent
eventId={parsed.data}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
/>
);
case "nevent":
return (
<EmbeddedEvent
eventId={parsed.data.id}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
/>
);
case "naddr":
return (
<EmbeddedEvent
addressPointer={parsed.data}
onOpen={(pointer) => {
addWindow(
"open",
pointer,
`${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`,
);
}}
/>
);
default:
return <span className="text-muted-foreground">{cleanHref}</span>;
}
} catch (error) {
// If parsing fails, just render as a regular link
console.error("Failed to parse nostr link:", href, error);
return (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
>
{href}
</a>
);
}
}
/**
* Detail renderer for Kind 30023 - Long-form Article
* Displays full markdown content with metadata
*/
export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
const title = useMemo(() => getArticleTitle(event), [event]);
const summary = useMemo(() => getArticleSummary(event), [event]);
const published = useMemo(() => getArticlePublished(event), [event]);
// Format published date
const publishedDate = published
? new Date(published * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
return (
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
{/* Article Header */}
<header className="flex flex-col gap-4 border-b border-border pb-6">
{/* Title */}
{title && <h1 className="text-3xl font-bold">{title}</h1>}
{/* Summary */}
{summary && <p className="text-lg text-muted-foreground">{summary}</p>}
{/* Metadata */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>By</span>
<UserName pubkey={event.pubkey} className="font-semibold" />
</div>
{publishedDate && (
<>
<span></span>
<time>{publishedDate}</time>
</>
)}
</div>
</header>
{/* Article Content - Markdown */}
<article className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNostrMentions]}
skipHtml
urlTransform={(url) => {
if (url.startsWith("nostr:")) return url;
return defaultUrlTransform(url);
}}
components={{
// Disable images as requested
img: () => null,
// Handle nostr: links
a: ({ node, href, children, ...props }) => {
if (!href) return null;
// Render nostr: mentions inline
if (href.startsWith("nostr:")) {
return <NostrMention href={href} />;
}
// Regular links
return (
<a
href={href}
className="text-accent underline decoration-dotted"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
// Make pre elements display inline
pre: ({ node, children, ...props }) => (
<span className="inline" {...props}>
{children}
</span>
),
// Style adjustments for dark theme
h1: ({ node, ...props }) => (
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
),
p: ({ node, ...props }) => (
<p className="text-sm leading-relaxed mb-4" {...props} />
),
code: ({ node, inline, ...props }: any) => (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
/>
),
blockquote: ({ node, ...props }) => (
<blockquote
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
className="text-sm list-disc list-inside my-4 space-y-2"
{...props}
/>
),
ol: ({ node, ...props }) => (
<ol
className="text-sm list-decimal list-inside my-4 space-y-2"
{...props}
/>
),
hr: () => <hr className="my-4" />,
}}
>
{event.content}
</ReactMarkdown>
</article>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useMemo } from "react";
import { BaseEventContainer, BaseEventProps } from "./BaseEventRenderer";
import {
getArticleTitle,
getArticleSummary,
} from "applesauce-core/helpers/article";
/**
* Renderer for Kind 30023 - Long-form Article
* Displays article title and summary in feed
*/
export function Kind30023Renderer({ event, showTimestamp }: BaseEventProps) {
const title = useMemo(() => getArticleTitle(event), [event]);
const summary = useMemo(() => getArticleSummary(event), [event]);
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-2">
{/* Title */}
{title && (
<h3 className="text-lg font-bold text-foreground">{title}</h3>
)}
{/* Summary */}
{summary && (
<p className="text-sm text-muted-foreground line-clamp-3">
{summary}
</p>
)}
{/* No content fallback */}
{!title && !summary && (
<p className="text-sm text-muted-foreground italic">
(Untitled article)
</p>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,40 @@
import { Repeat2 } from "lucide-react";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { useGrimoire } from "@/core/state";
/**
* Renderer for Kind 6 - Reposts
* Displays repost indicator with the original event embedded
*/
export function Kind6Renderer({ event, showTimestamp }: BaseEventProps) {
const { addWindow } = useGrimoire();
// Get the event being reposted (e tag)
const eTag = event.tags.find((tag) => tag[0] === "e");
const repostedEventId = eTag?.[1];
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Repeat2 className="size-4" />
<span>reposted</span>
</div>
{repostedEventId && (
<EmbeddedEvent
eventId={repostedEventId}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
className="border border-muted rounded overflow-hidden"
/>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,150 @@
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import { Heart, ThumbsUp, ThumbsDown, Flame, Smile } from "lucide-react";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useMemo } from "react";
import { NostrEvent } from "@/types/nostr";
import { KindRenderer } from "./index";
/**
* Renderer for Kind 7 - Reactions
* Displays emoji/reaction with the event being reacted to
* Supports both e tags (event ID) and a tags (address/replaceable events)
*/
export function Kind7Renderer({ event, showTimestamp }: BaseEventProps) {
// Get the reaction content (usually an emoji)
const reaction = event.content || "❤️";
// NIP-30: Custom emoji support
// emoji tags format: ["emoji", "shortcode", "image_url"]
const emojiTags = event.tags.filter((tag) => tag[0] === "emoji");
const customEmojis = useMemo(() => {
const map: Record<string, string> = {};
emojiTags.forEach((tag) => {
if (tag[1] && tag[2]) {
map[tag[1]] = tag[2]; // shortcode -> image_url
}
});
return map;
}, [emojiTags]);
// Parse reaction content to detect custom emoji shortcodes
// Format: :shortcode: in the content
const parsedReaction = useMemo(() => {
const match = reaction.match(/^:([a-zA-Z0-9_-]+):$/);
if (match && customEmojis[match[1]]) {
return {
type: "custom" as const,
shortcode: match[1],
url: customEmojis[match[1]],
};
}
return {
type: "unicode" as const,
emoji: reaction,
};
}, [reaction, customEmojis]);
// Get the event being reacted to (e tag for regular events)
const eTag = event.tags.find((tag) => tag[0] === "e");
const reactedEventId = eTag?.[1];
const reactedRelay = eTag?.[2]; // Optional relay hint
// Get the address being reacted to (a tag for replaceable events)
const aTag = event.tags.find((tag) => tag[0] === "a");
const reactedAddress = aTag?.[1]; // Format: kind:pubkey:d-tag
// Parse a tag into components
const addressParts = useMemo(() => {
if (!reactedAddress) return null;
const parts = reactedAddress.split(":");
return {
kind: parseInt(parts[0], 10),
pubkey: parts[1],
dTag: parts[2],
};
}, [reactedAddress]);
// Create event pointer for fetching
const eventPointer = useMemo(() => {
if (reactedEventId) {
return {
id: reactedEventId,
relays: reactedRelay ? [reactedRelay] : undefined,
};
}
if (addressParts) {
return {
kind: addressParts.kind,
pubkey: addressParts.pubkey,
identifier: addressParts.dTag || "",
relays: [],
};
}
return undefined;
}, [reactedEventId, reactedRelay, addressParts]);
// Fetch the reacted event
const reactedEvent = useNostrEvent(eventPointer);
// Map common reactions to icons
const getReactionIcon = (content: string) => {
switch (content) {
case "❤️":
case "♥️":
case "+":
return <Heart className="size-4 fill-red-500 text-red-500" />;
case "👍":
return <ThumbsUp className="size-4 fill-green-500 text-green-500" />;
case "👎":
return <ThumbsDown className="size-4 fill-red-500 text-red-500" />;
case "🔥":
return <Flame className="size-4 fill-orange-500 text-orange-500" />;
case "😄":
case "😊":
return <Smile className="size-4 fill-yellow-500 text-yellow-500" />;
default:
return <span className="text-xl">{content}</span>;
}
};
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-2">
{/* Reaction indicator */}
<div className="flex items-center gap-2">
{parsedReaction.type === "custom" ? (
<img
src={parsedReaction.url}
alt={`:${parsedReaction.shortcode}:`}
title={`:${parsedReaction.shortcode}:`}
className="size-6 inline-block"
/>
) : (
getReactionIcon(parsedReaction.emoji)
)}
</div>
{/* Embedded event (if loaded) */}
{reactedEvent && (
<div className="border border-muted">
<EmbeddedEvent event={reactedEvent} />
</div>
)}
{/* Loading state */}
{reactedEventId && !reactedEvent && (
<div className="border border-muted p-2 text-xs text-muted-foreground">
Loading referenced event...
</div>
)}
</div>
</BaseEventContainer>
);
}
/**
* Embedded event renderer - uses KindRenderer for recursive rendering
*/
function EmbeddedEvent({ event }: { event: NostrEvent }) {
return <KindRenderer event={event} />;
}

View File

@@ -0,0 +1,111 @@
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import { Zap } from "lucide-react";
import { useMemo } from "react";
import { NostrEvent } from "@/types/nostr";
import {
getZapAmount,
getZapRequest,
getZapEventPointer,
getZapAddressPointer,
getZapSender,
isValidZap,
} from "applesauce-core/helpers/zap";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./index";
import { RichText } from "../RichText";
/**
* Renderer for Kind 9735 - Zap Receipts
* Displays zap amount, sender, and zapped content
*/
export function Kind9735Renderer({ event, showTimestamp }: BaseEventProps) {
// Validate zap
const isValid = useMemo(() => isValidZap(event), [event]);
// Get zap details using applesauce helpers
const zapSender = useMemo(() => getZapSender(event), [event]);
const zapAmount = useMemo(() => getZapAmount(event), [event]);
const zapRequest = useMemo(() => getZapRequest(event), [event]);
// Get zapped content pointer (e tag or a tag)
const eventPointer = useMemo(() => getZapEventPointer(event), [event]);
const addressPointer = useMemo(() => getZapAddressPointer(event), [event]);
const pointer = eventPointer || addressPointer;
// Fetch the zapped event
const zappedEvent = useNostrEvent(pointer || undefined);
// Get zap comment from request
const zapComment = useMemo(() => {
if (!zapRequest) return null;
return zapRequest.content || null;
}, [zapRequest]);
// Format amount (convert from msats to sats)
const amountInSats = useMemo(() => {
if (!zapAmount) return 0;
return Math.floor(zapAmount / 1000);
}, [zapAmount]);
if (!isValid) {
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="text-xs text-muted-foreground">Invalid zap receipt</div>
</BaseEventContainer>
);
}
// Override event.pubkey to show zap sender instead of receipt pubkey
const displayEvent = useMemo(
() => ({
...event,
pubkey: zapSender || event.pubkey,
}),
[event, zapSender],
);
return (
<BaseEventContainer event={displayEvent} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-2">
{/* Zap indicator */}
<div className="flex items-center gap-2">
<Zap className="size-5 fill-yellow-500 text-yellow-500" />
<span className="text-lg font-light text-yellow-500">
{amountInSats.toLocaleString("en", {
notation: "compact",
})}
</span>
<span className="text-xs text-muted-foreground">sats</span>
</div>
{/* Zap comment */}
{zapComment && (
<div className="text-sm">
<RichText content={zapComment} />
</div>
)}
{/* Embedded zapped event (if loaded) */}
{zappedEvent && (
<div className="border border-muted">
<EmbeddedEvent event={zappedEvent} />
</div>
)}
{/* Loading state */}
{pointer && !zappedEvent && (
<div className="border border-muted p-2 text-xs text-muted-foreground">
Loading zapped event...
</div>
)}
</div>
</BaseEventContainer>
);
}
/**
* Embedded event renderer - uses KindRenderer for recursive rendering
*/
function EmbeddedEvent({ event }: { event: NostrEvent }) {
return <KindRenderer event={event} />;
}

View File

@@ -0,0 +1,136 @@
import { useMemo } from "react";
import { ExternalLink } from "lucide-react";
import type { NostrEvent } from "@/types/nostr";
import {
getHighlightText,
getHighlightSourceEventPointer,
getHighlightSourceAddressPointer,
getHighlightSourceUrl,
getHighlightComment,
getHighlightContext,
} from "applesauce-core/helpers/highlight";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { UserName } from "../UserName";
import { useGrimoire } from "@/core/state";
/**
* Detail renderer for Kind 9802 - Highlight
* Shows highlighted text, comment, context, and embedded source event
*/
export function Kind9802DetailRenderer({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
const highlightText = useMemo(() => getHighlightText(event), [event]);
const comment = useMemo(() => getHighlightComment(event), [event]);
const context = useMemo(() => getHighlightContext(event), [event]);
const sourceUrl = useMemo(() => getHighlightSourceUrl(event), [event]);
// Get source event pointer (e tag) or address pointer (a tag)
const eventPointer = useMemo(
() => getHighlightSourceEventPointer(event),
[event],
);
const addressPointer = useMemo(
() => getHighlightSourceAddressPointer(event),
[event],
);
// Format created date
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
},
);
return (
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
{/* Highlight Header */}
<header className="flex flex-col gap-4 border-b border-border pb-6">
<h1 className="text-2xl font-bold">Highlight</h1>
{/* Metadata */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>By</span>
<UserName pubkey={event.pubkey} className="font-semibold" />
</div>
<span></span>
<time>{createdDate}</time>
</div>
</header>
{/* Highlighted Text */}
{highlightText && (
<blockquote className="border-l-4 border-muted pl-4 py-2 bg-muted/30">
<p className="text-base italic leading-relaxed text-muted-foreground">
{highlightText}
</p>
</blockquote>
)}
{/* Context (surrounding text) */}
{context && (
<div className="flex flex-col gap-2">
<div className="text-xs text-muted-foreground uppercase tracking-wide">
Context
</div>
<p className="text-sm text-muted-foreground italic">{context}</p>
</div>
)}
{/* Comment */}
{comment && (
<div className="flex flex-col gap-2">
<div className="text-xs text-muted-foreground uppercase tracking-wide">
Comment
</div>
<p className="text-sm leading-relaxed">{comment}</p>
</div>
)}
{/* Source URL */}
{sourceUrl && (
<div className="flex flex-col gap-2">
<div className="text-xs text-muted-foreground uppercase tracking-wide">
Source
</div>
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-accent underline decoration-dotted break-all"
>
<ExternalLink className="size-4 flex-shrink-0" />
<span>{sourceUrl}</span>
</a>
</div>
)}
{/* Embedded Source Event */}
{(eventPointer || addressPointer) && (
<div className="flex flex-col gap-2">
<div className="text-xs text-muted-foreground uppercase tracking-wide">
Highlighted From
</div>
<EmbeddedEvent
eventId={eventPointer?.id}
addressPointer={addressPointer}
onOpen={(pointer) => {
if (typeof pointer === "string") {
addWindow(
"open",
{ id: pointer },
`Event ${pointer.slice(0, 8)}...`,
);
} else {
addWindow("open", pointer, `Event`);
}
}}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useMemo } from "react";
import { BaseEventContainer, BaseEventProps } from "./BaseEventRenderer";
import { ExternalLink } from "lucide-react";
import {
getHighlightText,
getHighlightSourceUrl,
getHighlightComment,
} from "applesauce-core/helpers/highlight";
/**
* Renderer for Kind 9802 - Highlight
* Displays highlighted text with optional comment and source URL
*/
export function Kind9802Renderer({ event, showTimestamp }: BaseEventProps) {
const highlightText = useMemo(() => getHighlightText(event), [event]);
const sourceUrl = useMemo(() => getHighlightSourceUrl(event), [event]);
const comment = useMemo(() => getHighlightComment(event), [event]);
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="flex flex-col gap-2">
{/* Comment */}
{comment && <p className="text-sm text-foreground">{comment}</p>}
{/* Highlighted text */}
{highlightText && (
<blockquote className="border-l-4 border-muted pl-3 py-2 bg-muted/80">
<p className="text-sm italic">{highlightText}</p>
</blockquote>
)}
{/* Source URL */}
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-accent underline decoration-dotted"
>
<ExternalLink className="size-3 flex-shrink-0" />
<span className="truncate">{sourceUrl}</span>
</a>
)}
{/* No content fallback */}
{!highlightText && (
<p className="text-sm text-muted-foreground italic">
(Empty highlight)
</p>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,137 @@
# Kind Renderer System
A flexible system for rendering different Nostr event kinds with custom components while sharing universal properties like author info and event actions.
## Architecture
### Core Components
**`BaseEventRenderer.tsx`** - Universal components shared across all renderers:
- `EventAuthor` - Displays user info with profile and NIP-05
- `EventMenu` - Context menu with copy ID, view JSON actions
- `BaseEventContainer` - Wrapper with header and universal layout
**`index.tsx`** - Registry and main entry point:
- `KindRenderer` - Main component that routes to appropriate renderer
- `kindRenderers` - Registry mapping kinds to components
- `DefaultKindRenderer` - Fallback for unregistered kinds
## Creating a Custom Renderer
### 1. Create a new renderer file
Example: `Kind7Renderer.tsx` for Reactions
```tsx
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
export function Kind7Renderer({ event }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
{/* Your custom rendering logic here */}
<div className="text-sm">
reacted with {event.content}
</div>
</BaseEventContainer>
);
}
```
### 2. Register in the index
Edit `index.tsx`:
```tsx
import { Kind7Renderer } from "./Kind7Renderer";
const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
1: Kind1Renderer,
7: Kind7Renderer, // Add your renderer
// ...
};
// Export it
export { Kind7Renderer } from "./Kind7Renderer";
```
### 3. Use in feeds
The `KindRenderer` automatically picks the right renderer:
```tsx
import { KindRenderer } from "./kinds";
function FeedEvent({ event }) {
return <KindRenderer event={event} />;
}
```
## Available Props
All custom renderers receive `BaseEventProps`:
```tsx
interface BaseEventProps {
event: NostrEvent; // Full Nostr event with id, pubkey, content, tags, etc.
}
```
## Reusable Components
You can use these in custom renderers:
- `<EventAuthor pubkey={event.pubkey} />` - User profile display
- `<EventMenu event={event} />` - Context menu
- `<BaseEventContainer event={event}>` - Standard wrapper with header
Or build completely custom layouts without them.
## Examples
### Kind 1 - Short Text Note
```tsx
export function Kind1Renderer({ event }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
<RichText event={event} className="text-sm" />
</BaseEventContainer>
);
}
```
### Kind 6 - Repost
```tsx
export function Kind6Renderer({ event }: BaseEventProps) {
const eTag = event.tags.find((tag) => tag[0] === "e");
return (
<BaseEventContainer event={event}>
<div className="flex items-center gap-2">
<Repeat2 className="size-4" />
<span>reposted {eTag?.[1]}</span>
</div>
</BaseEventContainer>
);
}
```
### Kind 7 - Reaction
```tsx
export function Kind7Renderer({ event }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
<div className="flex items-center gap-2">
<span>reacted with</span>
<span className="text-xl">{event.content || "❤️"}</span>
</div>
</BaseEventContainer>
);
}
```
## Benefits
- **Consistency**: Universal author/menu UI across all kinds
- **Flexibility**: Each kind can have completely custom rendering
- **Extensibility**: Add new kinds without modifying existing code
- **Type Safety**: TypeScript ensures all renderers match the interface
- **Default Fallback**: Unknown kinds still render with basic info

View File

@@ -0,0 +1,71 @@
import type { BaseEventProps } from "./BaseEventRenderer";
import { Kind0Renderer } from "./Kind0Renderer";
import { Kind1Renderer } from "./Kind1Renderer";
import { Kind6Renderer } from "./Kind6Renderer";
import { Kind7Renderer } from "./Kind7Renderer";
import { Kind9735Renderer } from "./Kind9735Renderer";
import { Kind9802Renderer } from "./Kind9802Renderer";
import { Kind30023Renderer } from "./Kind30023Renderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer } from "./BaseEventRenderer";
/**
* Registry of kind-specific renderers
* Add custom renderers here for specific event kinds
*/
const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
0: Kind0Renderer, // Profile Metadata
1: Kind1Renderer, // Short Text Note
6: Kind6Renderer, // Repost
7: Kind7Renderer, // Reaction
1111: Kind1Renderer, // Post
9735: Kind9735Renderer, // Zap Receipt
9802: Kind9802Renderer, // Highlight
30023: Kind30023Renderer, // Long-form Article
};
/**
* Default renderer for kinds without custom implementations
* Shows basic event info with raw content
*/
function DefaultKindRenderer({ event, showTimestamp }: BaseEventProps) {
return (
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
<div className="text-sm text-muted-foreground">
<div className="text-xs mb-1">Kind {event.kind} event</div>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words">
{event.content || "(empty content)"}
</pre>
</div>
</BaseEventContainer>
);
}
/**
* Main KindRenderer component
* Automatically selects the appropriate renderer based on event kind
*/
export function KindRenderer({
event,
showTimestamp = false,
}: {
event: NostrEvent;
showTimestamp?: boolean;
}) {
const Renderer = kindRenderers[event.kind] || DefaultKindRenderer;
return <Renderer event={event} showTimestamp={showTimestamp} />;
}
/**
* Export individual renderers and base components for reuse
*/
export {
BaseEventContainer,
EventAuthor,
EventMenu,
} from "./BaseEventRenderer";
export type { BaseEventProps } from "./BaseEventRenderer";
export { Kind1Renderer } from "./Kind1Renderer";
export { Kind6Renderer } from "./Kind6Renderer";
export { Kind7Renderer } from "./Kind7Renderer";
export { Kind9735Renderer } from "./Kind9735Renderer";

View File

@@ -0,0 +1,25 @@
import { useNip05 } from "@/hooks/useNip05";
import { ProfileContent } from "applesauce-core/helpers";
export function QueryNip05({
pubkey,
nip05,
}: {
pubkey: string;
nip05: string;
}) {
const nip05pubkey = useNip05(nip05);
if (nip05pubkey === pubkey) return nip05.replace(/^_@/, "");
return null;
}
export default function Nip05({
pubkey,
profile,
}: {
pubkey: string;
profile: ProfileContent;
}) {
if (!profile?.nip05) return null;
return <QueryNip05 pubkey={pubkey} nip05={profile.nip05} />;
}

View File

@@ -0,0 +1,14 @@
import { useMemo } from "react";
import { nip19 } from "nostr-tools";
export default function Npub({ pubkey }: { pubkey: string }) {
const short = useMemo(() => {
const npub = nip19.npubEncode(pubkey);
return `${npub.slice(0, 8)}:${npub.slice(-8)}`;
}, [pubkey]);
return (
<span className="text-xs text-muted-foreground overflow-hidden text-ellipsis">
{short}
</span>
);
}

View File

@@ -0,0 +1,30 @@
import { cn } from "@/lib/utils";
import pool from "@/services/relay-pool";
import { useObservableMemo } from "applesauce-react/hooks";
import { Relay } from "applesauce-relay";
import { Server, ServerOff } from "lucide-react";
function RelayItem({ relay }: { relay: Relay }) {
const icon = "size-4";
return (
<div className="flex flex-row items-center gap-1">
{relay.connected ? (
<Server className={cn(icon, "text-green-300")} />
) : (
<ServerOff className={cn(icon, "text-destructive-foreground")} />
)}
<span className="text-xs">{relay.url}</span>
</div>
);
}
export default function RelayPool() {
const relays = useObservableMemo(() => pool.relays$, []);
return (
<div className="flex flex-col gap-1">
{Array.from(relays.entries()).map(([url, relay]) => (
<RelayItem key={url} relay={relay} />
))}
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { User, Circle } from "lucide-react";
import accounts from "@/services/accounts";
import { ExtensionSigner } from "applesauce-signers";
import { ExtensionAccount } from "applesauce-accounts/accounts";
import { useProfile } from "@/hooks/useProfile";
import { useObservableMemo } from "applesauce-react/hooks";
import { getDisplayName } from "@/lib/nostr-utils";
import { useGrimoire } from "@/core/state";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Nip05 from "./nip05";
function UserAvatar({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
return (
<Avatar className="size-4">
<AvatarImage
src={profile?.picture}
alt={getDisplayName(pubkey, profile)}
/>
<AvatarFallback>
{getDisplayName(pubkey, profile).slice(2)}
</AvatarFallback>
</Avatar>
);
}
function UserLabel({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
return (
<div className="flex flex-col gap-0">
<span className="text-sm">{getDisplayName(pubkey, profile)}</span>
{profile ? (
<span className="text-xs text-muted-foreground">
<Nip05 pubkey={pubkey} profile={profile} />
</span>
) : null}
</div>
);
}
export default function UserMenu() {
const account = useObservableMemo(() => accounts.active$, []);
const { state, addWindow } = useGrimoire();
const relays = state.activeAccount?.relays;
console.log("UserMenu: account", account?.pubkey);
console.log("UserMenu: state.activeAccount", state.activeAccount);
console.log("UserMenu: relays", relays);
function openProfile() {
if (!account?.pubkey) return;
addWindow(
"profile",
{ pubkey: account.pubkey },
`Profile ${account.pubkey.slice(0, 8)}...`,
);
}
async function login() {
try {
const signer = new ExtensionSigner();
const pubkey = await signer.getPublicKey();
const account = new ExtensionAccount(pubkey, signer);
accounts.addAccount(account);
accounts.setActive(account);
} catch (err) {
console.error(err);
}
}
async function logout() {
if (!account) return;
accounts.removeAccount(account);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="link">
{account ? (
<UserAvatar pubkey={account.pubkey} />
) : (
<User onClick={login} className="size-4 text-muted-foreground" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80" align="start">
{account ? (
<>
<DropdownMenuGroup>
<DropdownMenuLabel
className="cursor-pointer hover:bg-muted/50"
onClick={openProfile}
>
<UserLabel pubkey={account.pubkey} />
</DropdownMenuLabel>
</DropdownMenuGroup>
{relays && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Inbox Relays
</DropdownMenuLabel>
{relays.inbox.length > 0 ? (
relays.inbox.map((relay) => (
<div
key={relay.url}
className="flex items-center gap-2 px-2 py-1"
>
<Circle className="size-2 fill-green-500 text-green-500" />
<span className="text-xs font-mono text-muted-foreground truncate">
{relay.url}
</span>
</div>
))
) : (
<div className="px-2 py-1 text-xs text-muted-foreground italic">
No inbox relays configured
</div>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Outbox Relays
</DropdownMenuLabel>
{relays.outbox.length > 0 ? (
relays.outbox.map((relay) => (
<div
key={relay.url}
className="flex items-center gap-2 px-2 py-1"
>
<Circle className="size-2 fill-green-500 text-green-500" />
<span className="text-xs font-mono text-muted-foreground truncate">
{relay.url}
</span>
</div>
))
) : (
<div className="px-2 py-1 text-xs text-muted-foreground italic">
No outbox relays configured
</div>
)}
</DropdownMenuGroup>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>Log out</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={login}>Log in</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,198 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronRightIcon, BadgeCheck } from "lucide-react";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 px-2 py-1.5 text-sm outline-none focus:bg-muted/50 data-[state=open]:bg-muted/50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted/50 focus:text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted/50 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<BadgeCheck className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-sm border border-border bg-popover p-2 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

919
src/constants/kinds.ts Normal file
View File

@@ -0,0 +1,919 @@
import {
User,
MessageSquare,
Radio,
Users,
Lock,
Trash2,
Repeat,
Heart,
Award,
MessageCircle,
Eye,
EyeOff,
Image,
Video,
Hash,
FileText,
AlertCircle,
ShoppingBag,
Zap,
Pin,
Bookmark,
Settings,
List,
Smile,
FileCode,
GitBranch,
Flag,
Tag,
Calendar,
Wallet,
Package,
Map,
Globe,
Highlighter,
BarChart3,
Timer,
ListChecks,
Mic,
Key,
Cloud,
UserCheck,
UserX,
Shield,
type LucideIcon,
} from "lucide-react";
export interface EventKind {
kind: number | string;
name: string;
description: string;
nip: string;
icon: LucideIcon;
}
export const EVENT_KINDS: Record<number | string, EventKind> = {
// Core protocol kinds
0: {
kind: 0,
name: "Profile",
description: "User Metadata",
nip: "01",
icon: User,
},
1: {
kind: 1,
name: "Note",
description: "Short Text Note",
nip: "10",
icon: MessageSquare,
},
2: {
kind: 2,
name: "Relay Recommendation",
description: "Recommend Relay",
nip: "01",
icon: Radio,
},
3: {
kind: 3,
name: "Contacts",
description: "Follows",
nip: "02",
icon: Users,
},
4: {
kind: 4,
name: "Encrypted DM",
description: "Encrypted Direct Messages",
nip: "04",
icon: Lock,
},
5: {
kind: 5,
name: "Deletion",
description: "Event Deletion Request",
nip: "09",
icon: Trash2,
},
6: {
kind: 6,
name: "Repost",
description: "Repost",
nip: "18",
icon: Repeat,
},
7: {
kind: 7,
name: "Reaction",
description: "Reaction",
nip: "25",
icon: Heart,
},
8: {
kind: 8,
name: "Badge Award",
description: "Badge Award",
nip: "58",
icon: Award,
},
9: {
kind: 9,
name: "Chat",
description: "Chat Message",
nip: "C7",
icon: MessageCircle,
},
// Group chat
10: {
kind: 10,
name: "Group Reply",
description: "Group Chat Threaded Reply",
nip: "29",
icon: MessageCircle,
},
11: {
kind: 11,
name: "Thread",
description: "Thread",
nip: "7D",
icon: MessageSquare,
},
12: {
kind: 12,
name: "Group Thread",
description: "Group Thread Reply",
nip: "29",
icon: MessageCircle,
},
13: { kind: 13, name: "Seal", description: "Seal", nip: "59", icon: Lock },
14: {
kind: 14,
name: "Direct Message",
description: "Direct Message",
nip: "17",
icon: MessageSquare,
},
15: {
kind: 15,
name: "File Message",
description: "File Message",
nip: "17",
icon: FileText,
},
16: {
kind: 16,
name: "Generic Repost",
description: "Generic Repost",
nip: "18",
icon: Repeat,
},
17: {
kind: 17,
name: "Website Reaction",
description: "Reaction to a website",
nip: "25",
icon: Heart,
},
18: {
kind: 18,
name: "Repost",
description: "Repost with optional comment",
nip: "18",
icon: Repeat,
},
// Media
20: {
kind: 20,
name: "Picture",
description: "Picture",
nip: "68",
icon: Image,
},
21: {
kind: 21,
name: "Video",
description: "Video Event",
nip: "71",
icon: Video,
},
22: {
kind: 22,
name: "Short Video",
description: "Short-form Portrait Video Event",
nip: "71",
icon: Video,
},
// Channels
40: {
kind: 40,
name: "Channel Create",
description: "Channel Creation",
nip: "28",
icon: Hash,
},
41: {
kind: 41,
name: "Channel Metadata",
description: "Channel Metadata",
nip: "28",
icon: Settings,
},
42: {
kind: 42,
name: "Channel Message",
description: "Channel Message",
nip: "28",
icon: MessageSquare,
},
43: {
kind: 43,
name: "Channel Hide",
description: "Channel Hide Message",
nip: "28",
icon: EyeOff,
},
44: {
kind: 44,
name: "Channel Mute",
description: "Channel Mute User",
nip: "28",
icon: Eye,
},
// Special events
62: {
kind: 62,
name: "Vanish Request",
description: "Request to Vanish",
nip: "62",
icon: Trash2,
},
64: {
kind: 64,
name: "Chess",
description: "Chess (PGN)",
nip: "64",
icon: Package,
},
// Data vending machine
5000: {
kind: 5000,
name: "Job Request",
description: "Data vending machine job request",
nip: "90",
icon: BarChart3,
},
6000: {
kind: 6000,
name: "Job Result",
description: "Data vending machine job result",
nip: "90",
icon: BarChart3,
},
7000: {
kind: 7000,
name: "Job Feedback",
description: "Job feedback",
nip: "90",
icon: MessageCircle,
},
// Marketplace
1021: {
kind: 1021,
name: "Bid",
description: "Bid",
nip: "15",
icon: ShoppingBag,
},
1022: {
kind: 1022,
name: "Bid Confirm",
description: "Bid confirmation",
nip: "15",
icon: ShoppingBag,
},
// Content
1040: {
kind: 1040,
name: "Timestamp",
description: "OpenTimestamps",
nip: "03",
icon: Calendar,
},
1059: {
kind: 1059,
name: "Gift Wrap",
description: "Gift Wrap",
nip: "59",
icon: Package,
},
1063: {
kind: 1063,
name: "File Metadata",
description: "File Metadata",
nip: "94",
icon: FileText,
},
1068: {
kind: 1068,
name: "Poll",
description: "Poll",
nip: "88",
icon: ListChecks,
},
1018: {
kind: 1018,
name: "Poll Response",
description: "Response to a poll",
nip: "88",
icon: ListChecks,
},
1111: {
kind: 1111,
name: "Comment",
description: "Comment",
nip: "22",
icon: MessageCircle,
},
1244: {
kind: 1244,
name: "Voice Comment",
description: "Voice Message Comment",
nip: "A0",
icon: Mic,
},
1222: {
kind: 1222,
name: "Voice Message",
description: "Voice Message",
nip: "A0",
icon: MessageCircle,
},
1311: {
kind: 1311,
name: "Live Chat",
description: "Live Chat Message",
nip: "53",
icon: MessageCircle,
},
1337: {
kind: 1337,
name: "Code",
description: "Code Snippet",
nip: "C0",
icon: FileCode,
},
// Git stuff
1617: {
kind: 1617,
name: "Patches",
description: "Git Patches",
nip: "34",
icon: GitBranch,
},
1618: {
kind: 1618,
name: "Pull Request",
description: "Git Pull Requests",
nip: "34",
icon: GitBranch,
},
1619: {
kind: 1619,
name: "Git Status",
description: "Git repository status",
nip: "34",
icon: GitBranch,
},
1621: {
kind: 1621,
name: "Issue",
description: "Git Issues",
nip: "34",
icon: AlertCircle,
},
1622: {
kind: 1622,
name: "Release",
description: "Git Release Artifacts",
nip: "34",
icon: Package,
},
818: {
kind: 818,
name: "Merge Request",
description: "Merge Requests",
nip: "54",
icon: GitBranch,
},
// Moderation
1984: {
kind: 1984,
name: "Report",
description: "Reporting",
nip: "56",
icon: Flag,
},
1985: {
kind: 1985,
name: "Label",
description: "Label",
nip: "32",
icon: Tag,
},
// Community
4550: {
kind: 4550,
name: "Community Post",
description: "Community Post Approval",
nip: "72",
icon: Users,
},
// Torrents
2003: {
kind: 2003,
name: "Torrent",
description: "Torrent",
nip: "35",
icon: Package,
},
2004: {
kind: 2004,
name: "Torrent Comment",
description: "Torrent Comment",
nip: "35",
icon: MessageCircle,
},
// Zaps
9041: {
kind: 9041,
name: "Zap Goal",
description: "Zap Goal",
nip: "75",
icon: Zap,
},
9734: {
kind: 9734,
name: "Zap Request",
description: "Zap Request",
nip: "57",
icon: Zap,
},
9735: { kind: 9735, name: "Zap", description: "Zap", nip: "57", icon: Zap },
9802: {
kind: 9802,
name: "Highlight",
description: "Highlights",
nip: "84",
icon: Highlighter,
},
// Lists (kind 10000+)
10000: {
kind: 10000,
name: "Mute List",
description: "Mute list",
nip: "51",
icon: EyeOff,
},
10001: {
kind: 10001,
name: "Pin List",
description: "Pin list",
nip: "51",
icon: Pin,
},
10002: {
kind: 10002,
name: "Relay List",
description: "Relay List Metadata",
nip: "65",
icon: Radio,
},
10003: {
kind: 10003,
name: "Bookmarks",
description: "Bookmark list",
nip: "51",
icon: Bookmark,
},
10004: {
kind: 10004,
name: "Communities",
description: "Communities list",
nip: "51",
icon: Users,
},
10005: {
kind: 10005,
name: "Public Chats",
description: "Public chats list",
nip: "51",
icon: MessageCircle,
},
10006: {
kind: 10006,
name: "Blocked Relays",
description: "Blocked relays list",
nip: "51",
icon: Radio,
},
10007: {
kind: 10007,
name: "Search Relays",
description: "Search relays list",
nip: "51",
icon: Radio,
},
10009: {
kind: 10009,
name: "User Groups",
description: "User groups list",
nip: "51",
icon: Users,
},
10015: {
kind: 10015,
name: "Interests",
description: "Interests list",
nip: "51",
icon: Heart,
},
10030: {
kind: 10030,
name: "Emoji List",
description: "User emoji list",
nip: "51",
icon: Smile,
},
10096: {
kind: 10096,
name: "File Storage",
description: "File storage server list",
nip: "96",
icon: Cloud,
},
// Cashu
7374: {
kind: 7374,
name: "Cashu Token",
description: "Cashu Wallet Tokens",
nip: "60",
icon: Wallet,
},
7375: {
kind: 7375,
name: "Cashu Quote",
description: "Cashu Mint Quote",
nip: "60",
icon: Wallet,
},
7376: {
kind: 7376,
name: "Cashu Request",
description: "Cashu Melt Quote",
nip: "60",
icon: Wallet,
},
// Group Control
9000: {
kind: 9000,
name: "Group Admin",
description: "Group Control - Add User",
nip: "29",
icon: UserCheck,
},
9001: {
kind: 9001,
name: "Group Remove",
description: "Group Control - Remove User",
nip: "29",
icon: UserX,
},
9002: {
kind: 9002,
name: "Group Edit",
description: "Group Control - Edit Metadata",
nip: "29",
icon: Settings,
},
9003: {
kind: 9003,
name: "Group Add Permission",
description: "Group Control - Add Permission",
nip: "29",
icon: Shield,
},
9004: {
kind: 9004,
name: "Group Remove Permission",
description: "Group Control - Remove Permission",
nip: "29",
icon: Shield,
},
9005: {
kind: 9005,
name: "Group Delete",
description: "Group Control - Delete Event",
nip: "29",
icon: Trash2,
},
9006: {
kind: 9006,
name: "Group Create Invite",
description: "Group Control - Create Invite",
nip: "29",
icon: Users,
},
9007: {
kind: 9007,
name: "Group Join Request",
description: "Group Control - Join Request",
nip: "29",
icon: Users,
},
9021: {
kind: 9021,
name: "Group Metadata",
description: "Group Metadata",
nip: "29",
icon: Settings,
},
// Wallet & Auth
13194: {
kind: 13194,
name: "Wallet Info",
description: "Wallet Info",
nip: "47",
icon: Wallet,
},
22242: {
kind: 22242,
name: "Client Auth",
description: "Client Authentication",
nip: "42",
icon: Key,
},
23194: {
kind: 23194,
name: "Wallet Request",
description: "Wallet Request",
nip: "47",
icon: Wallet,
},
23195: {
kind: 23195,
name: "Wallet Response",
description: "Wallet Response",
nip: "47",
icon: Wallet,
},
24133: {
kind: 24133,
name: "Nostr Connect",
description: "Nostr Connect",
nip: "46",
icon: Key,
},
27235: {
kind: 27235,
name: "HTTP Auth",
description: "HTTP Authentication",
nip: "98",
icon: Key,
},
// Replaceable events (kind 30000+)
30000: {
kind: 30000,
name: "Follow Sets",
description: "Follow sets",
nip: "51",
icon: Users,
},
30001: {
kind: 30001,
name: "Generic Lists",
description: "Generic lists",
nip: "51",
icon: List,
},
30002: {
kind: 30002,
name: "Relay Sets",
description: "Relay sets",
nip: "51",
icon: Radio,
},
30003: {
kind: 30003,
name: "Bookmark Sets",
description: "Bookmark sets",
nip: "51",
icon: Bookmark,
},
30008: {
kind: 30008,
name: "Profile Badges",
description: "Profile Badges",
nip: "58",
icon: Award,
},
30009: {
kind: 30009,
name: "Badge Definition",
description: "Badge Definition",
nip: "58",
icon: Award,
},
30017: {
kind: 30017,
name: "Stall",
description: "Create or update a stall",
nip: "15",
icon: ShoppingBag,
},
30018: {
kind: 30018,
name: "Product",
description: "Create or update a product",
nip: "15",
icon: ShoppingBag,
},
30023: {
kind: 30023,
name: "Article",
description: "Long-form Content",
nip: "23",
icon: FileText,
},
30024: {
kind: 30024,
name: "Draft Article",
description: "Draft Long-form Content",
nip: "23",
icon: FileText,
},
30030: {
kind: 30030,
name: "Emoji Sets",
description: "Emoji sets",
nip: "51",
icon: Smile,
},
30078: {
kind: 30078,
name: "App Data",
description: "Application-specific Data",
nip: "78",
icon: Settings,
},
30311: {
kind: 30311,
name: "Live Event",
description: "Live Event",
nip: "53",
icon: Video,
},
30312: {
kind: 30312,
name: "Live Chat Msg",
description: "Live Chat Message (Deprecated)",
nip: "53",
icon: MessageCircle,
},
30313: {
kind: 30313,
name: "Live Status",
description: "Live Event Status",
nip: "53",
icon: Timer,
},
30315: {
kind: 30315,
name: "Status",
description: "User Statuses",
nip: "38",
icon: MessageSquare,
},
30402: {
kind: 30402,
name: "Classified",
description: "Classified Listing",
nip: "99",
icon: ShoppingBag,
},
30403: {
kind: 30403,
name: "Classified Draft",
description: "Draft Classified Listing",
nip: "99",
icon: ShoppingBag,
},
30617: {
kind: 30617,
name: "Repo Announce",
description: "Repository announcements",
nip: "34",
icon: GitBranch,
},
30818: {
kind: 30818,
name: "Wiki",
description: "Wiki article",
nip: "54",
icon: FileText,
},
30819: {
kind: 30819,
name: "Wiki Redirect",
description: "Redirects to a wiki article",
nip: "54",
icon: FileText,
},
31922: {
kind: 31922,
name: "Calendar Event",
description: "Date-Based Calendar Event",
nip: "52",
icon: Calendar,
},
31923: {
kind: 31923,
name: "Time Event",
description: "Time-Based Calendar Event",
nip: "52",
icon: Calendar,
},
31924: {
kind: 31924,
name: "Calendar",
description: "Calendar",
nip: "52",
icon: Calendar,
},
31925: {
kind: 31925,
name: "Calendar RSVP",
description: "Calendar Event RSVP",
nip: "52",
icon: Calendar,
},
31989: {
kind: 31989,
name: "Handler Rec",
description: "Handler recommendation",
nip: "89",
icon: Settings,
},
34550: {
kind: 34550,
name: "Community",
description: "Community Definition",
nip: "72",
icon: Users,
},
37516: {
kind: 37516,
name: "Geocache",
description: "Geocache listing",
nip: "geocaching",
icon: Map,
},
39701: {
kind: 39701,
name: "Web Bookmarks",
description: "Web bookmarks",
nip: "B0",
icon: Globe,
},
};
export function getKindInfo(kind: number): EventKind | undefined {
return EVENT_KINDS[kind];
}
export function getKindName(kind: number): string {
return EVENT_KINDS[kind]?.name || `Kind ${kind}`;
}
export function getKindIcon(kind: number): LucideIcon {
return EVENT_KINDS[kind]?.icon || MessageSquare;
}

203
src/constants/nips.ts Normal file
View File

@@ -0,0 +1,203 @@
/**
* List of valid NIPs from https://github.com/nostr-protocol/nips
* Includes both numeric (01-99) and hexadecimal (7D, A0, etc.) identifiers
*/
export const VALID_NIPS = [
// Numeric NIPs
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"13",
"14",
"15",
"17",
"18",
"19",
"21",
"22",
"23",
"24",
"25",
"26",
"27",
"28",
"29",
"30",
"31",
"32",
"34",
"35",
"36",
"37",
"38",
"39",
"40",
"42",
"43",
"44",
"45",
"46",
"47",
"48",
"49",
"50",
"51",
"52",
"53",
"54",
"55",
"56",
"57",
"58",
"59",
"60",
"61",
"62",
"64",
"65",
"66",
"68",
"69",
"70",
"71",
"72",
"73",
"75",
"77",
"78",
"84",
"86",
"87",
"88",
"89",
"90",
"92",
"94",
"96",
"98",
"99",
// Hexadecimal NIPs
"7D",
"A0",
"B0",
"B7",
"BE",
"C0",
"C7",
"EE",
] as const;
export type NipId = (typeof VALID_NIPS)[number];
/**
* NIP titles from https://github.com/nostr-protocol/nips
*/
export const NIP_TITLES: Record<string, string> = {
"01": "Basic protocol flow description",
"02": "Follow List",
"03": "OpenTimestamps Attestations for Events",
"04": "Encrypted Direct Message",
"05": "Mapping Nostr keys to DNS-based internet identifiers",
"06": "Basic key derivation from mnemonic seed phrase",
"07": "window.nostr capability for web browsers",
"08": "Handling Mentions",
"09": "Event Deletion Request",
"10": "Text Notes and Threads",
"11": "Relay Information Document",
"13": "Proof of Work",
"14": "Subject tag in text events",
"15": "Nostr Marketplace",
"17": "Private Direct Messages",
"18": "Reposts",
"19": "bech32-encoded entities",
"21": "nostr: URI scheme",
"22": "Comment",
"23": "Long-form Content",
"24": "Extra metadata fields and tags",
"25": "Reactions",
"26": "Delegated Event Signing",
"27": "Text Note References",
"28": "Public Chat",
"29": "Relay-based Groups",
"30": "Custom Emoji",
"31": "Dealing with Unknown Events",
"32": "Labeling",
"34": "git stuff",
"35": "Torrents",
"36": "Sensitive Content",
"37": "Draft Events",
"38": "User Statuses",
"39": "External Identities in Profiles",
"40": "Expiration Timestamp",
"42": "Authentication of clients to relays",
"43": "Relay Access Metadata and Requests",
"44": "Encrypted Payloads (Versioned)",
"45": "Counting results",
"46": "Nostr Remote Signing",
"47": "Nostr Wallet Connect",
"48": "Proxy Tags",
"49": "Private Key Encryption",
"50": "Search Capability",
"51": "Lists",
"52": "Calendar Events",
"53": "Live Activities",
"54": "Wiki",
"55": "Android Signer Application",
"56": "Reporting",
"57": "Lightning Zaps",
"58": "Badges",
"59": "Gift Wrap",
"60": "Cashu Wallet",
"61": "Nutzaps",
"62": "Request to Vanish",
"64": "Chess (PGN)",
"65": "Relay List Metadata",
"66": "Relay Discovery and Liveness Monitoring",
"68": "Picture-first feeds",
"69": "Peer-to-peer Order events",
"70": "Protected Events",
"71": "Video Events",
"72": "Moderated Communities",
"73": "External Content IDs",
"75": "Zap Goals",
"77": "Negentropy Syncing",
"78": "Application-specific data",
"7D": "Threads",
"84": "Highlights",
"86": "Relay Management API",
"87": "Ecash Mint Discoverability",
"88": "Polls",
"89": "Recommended Application Handlers",
"90": "Data Vending Machines",
"92": "Media Attachments",
"94": "File Metadata",
"96": "HTTP File Storage Integration",
"98": "HTTP Auth",
"99": "Classified Listings",
A0: "Voice Messages",
B0: "Web Bookmarks",
B7: "Blossom",
BE: "Nostr BLE Communications Protocol",
C0: "Code Snippets",
C7: "Chats",
EE: "E2EE Messaging using MLS Protocol",
};
export const NIP_REPO_RAW_URL =
"https://raw.githubusercontent.com/nostr-protocol/nips/master";
export function getNipUrl(nipId: string): string {
return `${NIP_REPO_RAW_URL}/${nipId}.md`;
}
export function getNipTitle(nipId: string): string {
return NIP_TITLES[nipId] || `NIP-${nipId}`;
}

254
src/core/logic.ts Normal file
View File

@@ -0,0 +1,254 @@
import { v4 as uuidv4 } from "uuid";
import type { MosaicNode } from "react-mosaic-component";
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
/**
* Creates a new, empty workspace.
*/
export const createWorkspace = (
state: GrimoireState,
label: string,
): GrimoireState => {
const newId = uuidv4();
return {
...state,
activeWorkspaceId: newId,
workspaces: {
...state.workspaces,
[newId]: {
id: newId,
label,
layout: null,
windowIds: [],
},
},
};
};
/**
* Adds a window to the global store and to the active workspace.
*/
export const addWindow = (
state: GrimoireState,
payload: { appId: string; title: string; props: any },
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
const newWindowId = uuidv4();
const newWindow: WindowInstance = {
id: newWindowId,
appId: payload.appId as any,
title: payload.title,
props: payload.props,
};
// Simple Binary Split Logic
let newLayout: MosaicNode<string>;
if (ws.layout === null) {
newLayout = newWindowId;
} else {
newLayout = {
direction: "row",
first: ws.layout,
second: newWindowId,
splitPercentage: 50,
};
}
return {
...state,
windows: {
...state.windows,
[newWindowId]: newWindow,
},
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: newLayout,
windowIds: [...ws.windowIds, newWindowId],
},
},
};
};
/**
* Recursively removes a window from the layout tree.
*/
const removeFromLayout = (
layout: MosaicNode<string> | null,
windowId: string,
): MosaicNode<string> | null => {
if (layout === null) {
return null;
}
if (typeof layout === "string") {
return layout === windowId ? null : layout;
}
const firstResult = removeFromLayout(layout.first, windowId);
const secondResult = removeFromLayout(layout.second, windowId);
if (firstResult === null && secondResult !== null) {
return secondResult;
}
if (secondResult === null && firstResult !== null) {
return firstResult;
}
if (firstResult === null && secondResult === null) {
return null;
}
if (firstResult === layout.first && secondResult === layout.second) {
return layout;
}
return {
...layout,
first: firstResult!,
second: secondResult!,
};
};
/**
* Removes a window from the active workspace's layout and windowIds.
* Also removes the window from the global windows object.
*/
export const removeWindow = (
state: GrimoireState,
windowId: string,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
const newLayout = removeFromLayout(ws.layout, windowId);
const newWindowIds = ws.windowIds.filter((id) => id !== windowId);
// Remove from global windows object
const { [windowId]: removedWindow, ...remainingWindows } = state.windows;
return {
...state,
windows: remainingWindows,
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: newLayout,
windowIds: newWindowIds,
},
},
};
};
/**
* Moves a window from current workspace to target workspace.
*/
export const moveWindowToWorkspace = (
state: GrimoireState,
windowId: string,
targetWorkspaceId: string,
): GrimoireState => {
const currentId = state.activeWorkspaceId;
const currentWs = state.workspaces[currentId];
const targetWs = state.workspaces[targetWorkspaceId];
if (!targetWs) {
return state;
}
const newCurrentLayout = removeFromLayout(currentWs.layout, windowId);
const newCurrentWindowIds = currentWs.windowIds.filter(
(id) => id !== windowId,
);
let newTargetLayout: MosaicNode<string>;
if (targetWs.layout === null) {
newTargetLayout = windowId;
} else {
newTargetLayout = {
direction: "row",
first: targetWs.layout,
second: windowId,
splitPercentage: 50,
};
}
return {
...state,
workspaces: {
...state.workspaces,
[currentId]: {
...currentWs,
layout: newCurrentLayout,
windowIds: newCurrentWindowIds,
},
[targetWorkspaceId]: {
...targetWs,
layout: newTargetLayout,
windowIds: [...targetWs.windowIds, windowId],
},
},
};
};
export const updateLayout = (
state: GrimoireState,
layout: MosaicNode<string> | null,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
return {
...state,
workspaces: {
...state.workspaces,
[activeId]: {
...state.workspaces[activeId],
layout,
},
},
};
};
/**
* Sets the active account (pubkey).
*/
export const setActiveAccount = (
state: GrimoireState,
pubkey: string | undefined,
): GrimoireState => {
if (!pubkey) {
return {
...state,
activeAccount: undefined,
};
}
return {
...state,
activeAccount: {
pubkey,
relays: state.activeAccount?.relays,
},
};
};
/**
* Updates the relay list for the active account.
*/
export const setActiveAccountRelays = (
state: GrimoireState,
relays: UserRelays,
): GrimoireState => {
if (!state.activeAccount) {
return state;
}
return {
...state,
activeAccount: {
...state.activeAccount,
relays,
},
};
};

111
src/core/state.ts Normal file
View File

@@ -0,0 +1,111 @@
import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { GrimoireState, AppId } from "@/types/app";
import * as Logic from "./logic";
// Initial State Definition
const initialState: GrimoireState = {
windows: {
"win-1": {
id: "win-1",
appId: "win",
title: "WIN - Window Tree",
props: {},
},
"feed-1": {
id: "feed-1",
appId: "feed",
title: "FEED - Nostr Feed",
props: {},
},
"nip-1": {
id: "nip-1",
appId: "nip",
title: "NIP-01 - Basic protocol",
props: { number: "01" },
},
"kind-1": {
id: "kind-1",
appId: "kind",
title: "KIND-1 - Short Text Note",
props: { number: "1" },
},
"man-1": {
id: "man-1",
appId: "man",
title: "MAN - Help",
props: { cmd: "help" },
},
},
activeWorkspaceId: "default",
workspaces: {
default: {
id: "default",
label: "1",
windowIds: ["win-1", "feed-1", "nip-1", "kind-1", "man-1"],
layout: {
direction: "row",
first: {
direction: "column",
first: "win-1",
second: "feed-1",
splitPercentage: 50,
},
second: {
direction: "column",
first: {
direction: "row",
first: "nip-1",
second: "kind-1",
splitPercentage: 50,
},
second: "man-1",
splitPercentage: 50,
},
splitPercentage: 50,
},
},
},
};
// Persistence Atom
export const grimoireStateAtom = atomWithStorage<GrimoireState>(
"grimoire_v6",
initialState,
);
// The Hook
export const useGrimoire = () => {
const [state, setState] = useAtom(grimoireStateAtom);
return {
state,
activeWorkspace: state.workspaces[state.activeWorkspaceId],
createWorkspace: () => {
const count = Object.keys(state.workspaces).length + 1;
setState((prev) => Logic.createWorkspace(prev, count.toString()));
},
addWindow: (appId: AppId, props: any, title?: string) =>
setState((prev) =>
Logic.addWindow(prev, {
appId,
props,
title: title || appId.toUpperCase(),
}),
),
removeWindow: (id: string) =>
setState((prev) => Logic.removeWindow(prev, id)),
moveWindowToWorkspace: (windowId: string, targetWorkspaceId: string) =>
setState((prev) =>
Logic.moveWindowToWorkspace(prev, windowId, targetWorkspaceId),
),
updateLayout: (layout: any) =>
setState((prev) => Logic.updateLayout(prev, layout)),
setActiveWorkspace: (id: string) =>
setState((prev) => ({ ...prev, activeWorkspaceId: id })),
setActiveAccount: (pubkey: string | undefined) =>
setState((prev) => Logic.setActiveAccount(prev, pubkey)),
setActiveAccountRelays: (relays: any) =>
setState((prev) => Logic.setActiveAccountRelays(prev, relays)),
};
};

104
src/hooks/useAccountSync.ts Normal file
View File

@@ -0,0 +1,104 @@
import { useEffect } from "react";
import { useObservableMemo, useEventStore } from "applesauce-react/hooks";
import accounts from "@/services/accounts";
import { useGrimoire } from "@/core/state";
import { getInboxes, getOutboxes } from "applesauce-core/helpers";
import { addressLoader } from "@/services/loaders";
import type { RelayInfo, UserRelays } from "@/types/app";
/**
* Hook that syncs active account with Grimoire state and fetches relay lists
*/
export function useAccountSync() {
const { state, setActiveAccount, setActiveAccountRelays } = useGrimoire();
const eventStore = useEventStore();
// Watch active account from accounts service
const activeAccount = useObservableMemo(() => accounts.active$, []);
// Sync active account pubkey to state
useEffect(() => {
console.log("useAccountSync: activeAccount changed", activeAccount?.pubkey);
if (activeAccount?.pubkey !== state.activeAccount?.pubkey) {
console.log(
"useAccountSync: setting active account",
activeAccount?.pubkey,
);
setActiveAccount(activeAccount?.pubkey);
}
}, [activeAccount?.pubkey, state.activeAccount?.pubkey, setActiveAccount]);
// Fetch and watch relay list (kind 10002) when account changes
useEffect(() => {
if (!activeAccount?.pubkey) {
console.log("useAccountSync: no active account, skipping relay fetch");
return;
}
const pubkey = activeAccount.pubkey;
console.log("useAccountSync: fetching relay list for", pubkey);
// Subscribe to kind 10002 (relay list)
const subscription = addressLoader({
kind: 10002,
pubkey,
identifier: "",
}).subscribe();
// Watch for relay list event in store
const storeSubscription = eventStore
.replaceable(10002, pubkey, "")
.subscribe((relayListEvent) => {
console.log(
"useAccountSync: relay list event received",
relayListEvent,
);
if (!relayListEvent) return;
// Parse inbox and outbox relays
const inboxRelays = getInboxes(relayListEvent);
const outboxRelays = getOutboxes(relayListEvent);
// Get all relays from tags
const allRelays: RelayInfo[] = [];
const seenUrls = new Set<string>();
for (const tag of relayListEvent.tags) {
if (tag[0] === "r") {
const url = tag[1];
if (seenUrls.has(url)) continue;
seenUrls.add(url);
const type = tag[2];
allRelays.push({
url,
read: !type || type === "read",
write: !type || type === "write",
});
}
}
const relays: UserRelays = {
inbox: inboxRelays.map((url) => ({
url,
read: true,
write: false,
})),
outbox: outboxRelays.map((url) => ({
url,
read: false,
write: true,
})),
all: allRelays,
};
console.log("useAccountSync: parsed relays", relays);
setActiveAccountRelays(relays);
});
return () => {
subscription.unsubscribe();
storeSubscription.unsubscribe();
};
}, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]);
}

72
src/hooks/useNip.ts Normal file
View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import db from "@/services/db";
import { getNipUrl } from "@/constants/nips";
interface UseNipResult {
content: string | null;
loading: boolean;
error: Error | null;
}
export function useNip(nipId: string): UseNipResult {
const [error, setError] = useState<Error | null>(null);
const [isFetching, setIsFetching] = useState(false);
// Live query that reactively updates when the NIP is cached
const cached = useLiveQuery(() => db.nips.get(nipId), [nipId]);
useEffect(() => {
// If we already have it cached or are currently fetching, don't fetch again
if (cached || isFetching) return;
let isMounted = true;
setIsFetching(true);
async function fetchNip() {
try {
setError(null);
// Fetch from GitHub
const url = getNipUrl(nipId);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch NIP-${nipId}: ${response.statusText}`,
);
}
const markdown = await response.text();
// Cache the result (live query will auto-update)
await db.nips.put({
id: nipId,
content: markdown,
fetchedAt: Date.now(),
});
if (isMounted) {
setIsFetching(false);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err : new Error("Unknown error"));
setIsFetching(false);
}
}
}
fetchNip();
return () => {
isMounted = false;
};
}, [nipId, cached, isFetching]);
return {
content: cached?.content ?? null,
loading: !cached && isFetching,
error,
};
}

20
src/hooks/useNip05.ts Normal file
View File

@@ -0,0 +1,20 @@
import db from "@/services/db";
import { queryProfile } from "nostr-tools/nip05";
import { useLiveQuery } from "dexie-react-hooks";
import { useEffect } from "react";
export function useNip05(nip05: string) {
const resolved = useLiveQuery(() => db.nip05.get(nip05), [nip05]);
useEffect(() => {
if (resolved) return;
queryProfile(nip05).then((result) => {
if (result) {
db.nip05.put({
nip05,
pubkey: result.pubkey,
});
}
});
}, [resolved, nip05]);
return resolved?.pubkey;
}

124
src/hooks/useNostrEvent.ts Normal file
View File

@@ -0,0 +1,124 @@
import { useEffect } from "react";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { eventLoader, addressLoader } from "@/services/loaders";
import type { NostrEvent } from "@/types/nostr";
/**
* Type guard to check if pointer is an EventPointer
*/
function isEventPointer(
pointer: EventPointer | AddressPointer,
): pointer is EventPointer {
return "id" in pointer;
}
/**
* Type guard to check if pointer is an AddressPointer
*/
function isAddressPointer(
pointer: EventPointer | AddressPointer,
): pointer is AddressPointer {
return "kind" in pointer && "pubkey" in pointer;
}
/**
* Unified hook for fetching Nostr events by pointer
* Supports string ID, EventPointer, and AddressPointer
* @param pointer - string ID, EventPointer, or AddressPointer
* @returns Event or undefined
*/
export function useNostrEvent(
pointer:
| string
| EventPointer
| AddressPointer
| { kind: number; pubkey: string; identifier: string }
| undefined,
): NostrEvent | undefined {
const eventStore = useEventStore();
// Watch event store for the specific event
const event = useObservableMemo(() => {
if (!pointer) return undefined;
// Handle string ID
if (typeof pointer === "string") {
return eventStore.event(pointer);
}
if (isEventPointer(pointer)) {
// For EventPointer, query by ID
return eventStore.event(pointer.id);
} else if (isAddressPointer(pointer)) {
// For AddressPointer, query replaceable event
return eventStore.replaceable(
pointer.kind,
pointer.pubkey,
pointer.identifier || "",
);
}
return undefined;
}, [pointer]);
// Trigger event loading with appropriate loader
// Use JSON.stringify for dependency to handle object changes
const pointerKey = pointer
? typeof pointer === "string"
? pointer
: JSON.stringify(pointer)
: null;
useEffect(() => {
if (!pointer) return;
// Handle string ID
if (typeof pointer === "string") {
console.log("[useNostrEvent] Loading event by ID:", pointer);
const subscription = eventLoader({ id: pointer }).subscribe();
return () => subscription.unsubscribe();
}
if (isEventPointer(pointer)) {
console.log("[useNostrEvent] Loading event by EventPointer:", pointer);
const subscription = eventLoader(pointer).subscribe();
return () => subscription.unsubscribe();
} else if (isAddressPointer(pointer)) {
console.log("[useNostrEvent] Loading event by AddressPointer:", pointer);
const subscription = addressLoader(pointer).subscribe({
next: (event) =>
console.log("[useNostrEvent] Received event:", event.id),
error: (err) => console.error("[useNostrEvent] Error loading:", err),
complete: () => console.log("[useNostrEvent] Loading complete"),
});
return () => {
console.log("[useNostrEvent] Unsubscribing from addressLoader");
subscription.unsubscribe();
};
} else {
console.warn("[useNostrEvent] Unknown pointer type:", pointer);
}
}, [pointerKey]);
return event;
}
/**
* Convenience hook for fetching events by ID only
* @param eventId - Event ID to fetch
* @param relayUrl - Optional relay URL hint
* @returns Event or undefined
*/
export function useEventById(
eventId: string | undefined,
relayUrl?: string,
): NostrEvent | undefined {
const pointer = eventId
? ({
id: eventId,
relays: relayUrl ? [relayUrl] : undefined,
} as EventPointer)
: undefined;
return useNostrEvent(pointer);
}

32
src/hooks/useProfile.ts Normal file
View File

@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { kinds } from "nostr-tools";
import { profileLoader } from "@/services/loaders";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { ProfileContent } from "applesauce-core/helpers";
import { ProfileModel } from "applesauce-core/models/profile";
export function useProfile(pubkey: string): ProfileContent | undefined {
const eventStore = useEventStore();
const profile = useObservableMemo(
() => eventStore.model(ProfileModel, pubkey),
[eventStore, pubkey],
);
// Fetch profile if not in store (only runs once per pubkey)
useEffect(() => {
if (profile) return; // Already have the event
const sub = profileLoader({ kind: kinds.Metadata, pubkey }).subscribe({
next: (fetchedEvent) => {
if (fetchedEvent) {
eventStore.add(fetchedEvent);
}
},
});
return () => sub.unsubscribe();
}, [pubkey, eventStore]); // Removed event and loading from deps
return profile;
}

128
src/hooks/useReqTimeline.ts Normal file
View File

@@ -0,0 +1,128 @@
import { useState, useEffect, useMemo } from "react";
import pool from "@/services/relay-pool";
import type { NostrEvent, Filter } from "nostr-tools";
interface UseReqTimelineOptions {
limit?: number;
stream?: boolean;
}
interface UseReqTimelineReturn {
events: NostrEvent[];
loading: boolean;
error: Error | null;
eoseReceived: boolean;
}
/**
* Hook for REQ command - queries ONLY specified relays using pool.req()
* Stores results in memory (not EventStore) and returns them sorted by created_at
* @param id - Unique identifier for this timeline (for caching)
* @param filters - Nostr filter object
* @param relays - Array of relay URLs (ONLY these relays will be queried)
* @param options - Additional options like limit and stream (keep connection open after EOSE)
* @returns Object containing events array (sorted newest first), loading state, and error
*/
export function useReqTimeline(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseReqTimelineOptions = { limit: 50 },
): UseReqTimelineReturn {
const { limit, stream = false } = options;
const [events, setEvents] = useState<NostrEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [eoseReceived, setEoseReceived] = useState(false);
// Use pool.req() directly to query relays
useEffect(() => {
if (relays.length === 0) {
setLoading(false);
setEvents([]);
return;
}
console.log("REQ: Starting query", { relays, filters, limit, stream });
setLoading(true);
setError(null);
setEoseReceived(false);
const collectedEvents = new Map<string, NostrEvent>();
// 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,
}));
// Use pool.req() for direct relay querying
// pool.req() returns an Observable of events
const observable = pool.req(relays, filtersWithLimit);
const subscription = observable.subscribe(
(response) => {
// Response can be an event or 'EOSE' string
if (typeof response === "string") {
console.log("REQ: EOSE received");
setEoseReceived(true);
if (!stream) {
setLoading(false);
}
} else {
const event = response as NostrEvent;
console.log("REQ: Event received", event.id);
// Use Map to deduplicate by event ID
collectedEvents.set(event.id, event);
// Update state with deduplicated events
setEvents(Array.from(collectedEvents.values()));
}
},
(err: Error) => {
console.error("REQ: Error", err);
setError(err);
setLoading(false);
},
() => {
console.log("REQ: Query complete", {
total: collectedEvents.size,
stream,
});
// Only set loading to false if not streaming
if (!stream) {
setLoading(false);
}
},
);
// Set a timeout to prevent infinite loading (only for non-streaming queries)
const timeout = !stream
? setTimeout(() => {
console.warn("REQ: Query timeout, forcing completion");
setLoading(false);
}, 10000)
: undefined;
return () => {
if (timeout) {
clearTimeout(timeout);
}
subscription.unsubscribe();
};
}, [id, JSON.stringify(filters), relays.join(","), limit, stream]);
// Sort events by created_at (newest first)
const sortedEvents = useMemo(() => {
return [...events].sort((a, b) => b.created_at - a.created_at);
}, [events]);
return {
events: sortedEvents,
loading,
error,
eoseReceived,
};
}

80
src/hooks/useTimeline.ts Normal file
View File

@@ -0,0 +1,80 @@
import { useState, useEffect } from "react";
import type { NostrEvent, Filter } from "nostr-tools";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { createTimelineLoader } from "@/services/loaders";
import pool from "@/services/relay-pool";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
interface UseTimelineOptions {
limit?: number;
}
interface UseTimelineReturn {
events: NostrEvent[];
loading: boolean;
error: Error | null;
}
/**
* Hook for subscribing to a timeline of events from relays
* Uses applesauce loaders for efficient event loading and caching
* @param id - Unique identifier for this timeline (for caching)
* @param filters - Nostr filter object
* @param relays - Array of relay URLs
* @param options - Additional options like limit
* @returns Object containing events array, loading state, and error
*/
export function useTimeline(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseTimelineOptions = { limit: 20 },
): UseTimelineReturn {
const { limit } = options;
const eventStore = useEventStore();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Load events into store
useEffect(() => {
if (relays.length === 0) return;
const loader = createTimelineLoader(
pool,
relays.concat(AGGREGATOR_RELAYS),
filters,
{
eventStore,
limit,
},
);
setLoading(true);
setError(null);
const subscription = loader().subscribe({
error: (err: Error) => {
console.error("Timeline error:", err);
setError(err);
setLoading(false);
},
complete: () => {
setLoading(false);
},
});
return () => subscription.unsubscribe();
}, [id, relays.length, limit]);
// Watch store for matching events
const timeline = useObservableMemo(() => {
return eventStore.timeline(filters, false);
}, [id]);
const hasItems = timeline ? timeline.length > 0 : false;
return {
events: timeline || [],
loading: hasItems ? false : loading,
error,
};
}

225
src/index.css Normal file
View File

@@ -0,0 +1,225 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.3);
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 270 100% 70%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-mono;
}
}
@layer utilities {
.text-grimoire-gradient {
background: linear-gradient(
to bottom,
rgb(250 204 21),
/* yellow-400 */ rgb(251 146 60),
/* orange-400 */ rgb(168 85 247),
/* purple-500 */ rgb(34 211 238) /* cyan-400 */
);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
/* react-medium-image-zoom dark theme customization */
[data-rmiz-modal-overlay] {
background-color: rgba(12, 12, 18, 0.92) !important;
}
[data-rmiz-modal-content] {
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
}
/* React Mosaic Dark Theme Customization */
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme {
background: hsl(var(--background));
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window
.mosaic-window-toolbar {
background: hsl(var(--background));
border: none;
border-bottom: 1px solid hsl(var(--border));
border-radius: 0;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window
.mosaic-window-title {
color: hsl(var(--foreground));
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window {
background: hsl(var(--background));
outline: none;
border-radius: 0 !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window * {
border-radius: 0 !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window::before,
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window::after {
display: none;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme.bp4-dark .mosaic-window,
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme.bp4-dark .mosaic-preview {
box-shadow: none;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window
.mosaic-window-toolbar {
background: hsl(var(--muted));
border: none;
border-bottom: 1px solid hsl(var(--border));
color: hsl(var(--foreground));
height: 30px;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window
.mosaic-window-title {
color: hsl(var(--foreground));
font-family: inherit;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window
.mosaic-window-body {
background: hsl(var(--background));
color: hsl(var(--foreground));
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window-controls {
color: hsl(var(--muted-foreground));
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window-controls:hover {
color: hsl(var(--foreground));
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window-toolbar
.separator {
border-left: 1px solid hsl(var(--border));
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window-body-overlay {
background: hsl(var(--background));
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-preview {
background: hsl(var(--accent) / 0.3);
border: 2px solid hsl(var(--primary));
outline: none;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-drop-target {
border: 2px solid var(--border);
outline: none;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split:hover {
background: hsl(var(--primary));
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split.-row {
width: 4px;
margin: 0 -2px;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split.-column {
height: 4px;
margin: -2px 0;
}

104
src/lib/decode-parser.ts Normal file
View File

@@ -0,0 +1,104 @@
import { nip19 } from "nostr-tools";
export interface ParsedDecodeCommand {
bech32: string;
}
export type DecodedData =
| { type: "npub"; data: string }
| { type: "note"; data: string }
| { type: "nsec"; data: string }
| { type: "nprofile"; data: nip19.ProfilePointer }
| { type: "nevent"; data: nip19.EventPointer }
| { type: "naddr"; data: nip19.AddressPointer };
/**
* Parse DECODE command arguments
*
* Example:
* decode npub1...
* decode nevent1...
*/
export function parseDecodeCommand(args: string[]): ParsedDecodeCommand {
if (args.length !== 1) {
throw new Error("Usage: DECODE <bech32-identifier>");
}
const bech32 = args[0];
// Validate it's a nostr bech32 (starts with n and is reasonably long)
if (!bech32.startsWith("n") || bech32.length < 20) {
throw new Error("Invalid nostr bech32 identifier");
}
return { bech32 };
}
/**
* Decode a nostr bech32 identifier
*/
export function decodeNostr(bech32: string): DecodedData {
try {
const decoded = nip19.decode(bech32);
return decoded as DecodedData;
} catch (error) {
throw new Error(
`Failed to decode: ${error instanceof Error ? error.message : "unknown error"}`,
);
}
}
/**
* Re-encode with updated relays
*/
export function reencodeWithRelays(
decoded: DecodedData,
relays: string[],
originalBech32?: string,
): string {
switch (decoded.type) {
case "npub":
// Upgrade to nprofile with relays
return nip19.nprofileEncode({
pubkey: decoded.data,
relays,
});
case "nprofile":
// Update relays
return nip19.nprofileEncode({
...decoded.data,
relays,
});
case "note":
// Upgrade to nevent with relays
return nip19.neventEncode({
id: decoded.data,
relays,
});
case "nevent":
// Update relays
return nip19.neventEncode({
...decoded.data,
relays,
});
case "naddr":
// Update relays
return nip19.naddrEncode({
...decoded.data,
relays,
});
case "nsec":
// nsec doesn't support relays, return original
return originalBech32 || nip19.nsecEncode(decoded.data as any);
default:
throw new Error(
`Unsupported type for re-encoding: ${(decoded as any).type}`,
);
}
}

179
src/lib/encode-parser.ts Normal file
View File

@@ -0,0 +1,179 @@
import { nip19 } from "nostr-tools";
export type EncodeType = "npub" | "note" | "nevent" | "nprofile" | "naddr";
export interface ParsedEncodeCommand {
type: EncodeType;
value: string; // hex pubkey, event id, or "kind:pubkey:d-tag"
relays?: string[];
author?: string; // for nevent
}
/**
* Parse ENCODE command arguments
*
* Examples:
* encode npub <pubkey-hex>
* encode nprofile <pubkey-hex> --relay <url>
* encode note <event-id>
* encode nevent <event-id> --relay <url> --author <pubkey>
* encode naddr <kind>:<pubkey>:<d-tag> --relay <url>
*/
export function parseEncodeCommand(args: string[]): ParsedEncodeCommand {
if (args.length < 2) {
throw new Error(
"Usage: ENCODE <type> <value> [--relay <url>] [--author <pubkey>]",
);
}
const type = args[0].toLowerCase() as EncodeType;
const validTypes: EncodeType[] = [
"npub",
"note",
"nevent",
"nprofile",
"naddr",
];
if (!validTypes.includes(type)) {
throw new Error(
`Invalid type: ${type}. Must be one of: ${validTypes.join(", ")}`,
);
}
const value = args[1];
const relays: string[] = [];
let author: string | undefined;
// Parse flags
let i = 2;
while (i < args.length) {
const flag = args[i];
if (flag === "--relay" || flag === "-r") {
if (i + 1 >= args.length) {
throw new Error(`${flag} requires a relay URL`);
}
relays.push(args[i + 1]);
i += 2;
continue;
}
if (flag === "--author" || flag === "-a") {
if (i + 1 >= args.length) {
throw new Error(`${flag} requires a pubkey`);
}
author = args[i + 1];
i += 2;
continue;
}
throw new Error(`Unknown flag: ${flag}`);
}
// Validate based on type
validateEncodeInput(type, value, relays, author);
return {
type,
value,
relays: relays.length > 0 ? relays : undefined,
author,
};
}
function validateEncodeInput(
type: EncodeType,
value: string,
relays: string[],
author?: string,
) {
// Validate hex strings are 64 characters
if (type === "npub" || type === "nprofile") {
if (!/^[0-9a-f]{64}$/i.test(value)) {
throw new Error("Pubkey must be 64-character hex string");
}
}
if (type === "note") {
if (!/^[0-9a-f]{64}$/i.test(value)) {
throw new Error("Event ID must be 64-character hex string");
}
}
if (type === "nevent") {
if (!/^[0-9a-f]{64}$/i.test(value)) {
throw new Error("Event ID must be 64-character hex string");
}
if (author && !/^[0-9a-f]{64}$/i.test(author)) {
throw new Error("Author pubkey must be 64-character hex string");
}
}
if (type === "naddr") {
// Format: kind:pubkey:d-tag
const parts = value.split(":");
if (parts.length !== 3) {
throw new Error("naddr value must be in format: kind:pubkey:d-tag");
}
const [kindStr, pubkey, _identifier] = parts;
const kind = parseInt(kindStr, 10);
if (isNaN(kind)) {
throw new Error("Kind must be a number");
}
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
throw new Error("Pubkey must be 64-character hex string");
}
}
// Validate relay URLs
for (const relay of relays) {
try {
const url = new URL(relay);
if (!url.protocol.startsWith("ws")) {
throw new Error("Relay must be a WebSocket URL (ws:// or wss://)");
}
} catch {
throw new Error(`Invalid relay URL: ${relay}`);
}
}
}
/**
* Encode the parsed command to bech32
*/
export function encodeToNostr(cmd: ParsedEncodeCommand): string {
switch (cmd.type) {
case "npub":
return nip19.npubEncode(cmd.value);
case "note":
return nip19.noteEncode(cmd.value);
case "nprofile":
return nip19.nprofileEncode({
pubkey: cmd.value,
relays: cmd.relays || [],
});
case "nevent":
return nip19.neventEncode({
id: cmd.value,
relays: cmd.relays || [],
author: cmd.author,
});
case "naddr": {
const [kindStr, pubkey, identifier] = cmd.value.split(":");
return nip19.naddrEncode({
kind: parseInt(kindStr, 10),
pubkey,
identifier: identifier || "",
relays: cmd.relays || [],
});
}
default:
throw new Error(`Unsupported encode type: ${cmd.type}`);
}
}

17
src/lib/nip-kinds.ts Normal file
View File

@@ -0,0 +1,17 @@
import { EVENT_KINDS } from '@/constants/kinds'
/**
* Get all event kinds defined in a specific NIP
*/
export function getKindsForNip(nipId: string): number[] {
const kinds: number[] = []
for (const [kindKey, kindInfo] of Object.entries(EVENT_KINDS)) {
if (kindInfo.nip === nipId) {
const kindNum = typeof kindInfo.kind === 'number' ? kindInfo.kind : parseInt(kindKey)
kinds.push(kindNum)
}
}
return kinds.sort((a, b) => a - b)
}

59
src/lib/nip05.ts Normal file
View File

@@ -0,0 +1,59 @@
import { queryProfile } from "nostr-tools/nip05";
/**
* NIP-05 Identifier Resolution
* Resolves user@domain identifiers to Nostr pubkeys using nostr-tools
*/
/**
* Check if a string looks like a NIP-05 identifier (user@domain)
*/
export function isNip05(value: string): boolean {
if (!value) return false;
// Must match user@domain format
return /^[a-zA-Z0-9._-]+@[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}$/.test(value);
}
/**
* Resolve a NIP-05 identifier to a pubkey using nostr-tools
* @param nip05 - The NIP-05 identifier (user@domain or _@domain)
* @returns The hex pubkey or null if resolution fails
*/
export async function resolveNip05(nip05: string): Promise<string | null> {
if (!isNip05(nip05)) return null;
try {
const profile = await queryProfile(nip05);
if (!profile?.pubkey) {
console.warn(`NIP-05: No pubkey found for ${nip05}`);
return null;
}
console.log(`NIP-05: Resolved ${nip05}${profile.pubkey}`);
return profile.pubkey.toLowerCase();
} catch (error) {
console.warn(`NIP-05: Resolution failed for ${nip05}:`, error);
return null;
}
}
/**
* Resolve multiple NIP-05 identifiers in parallel
*/
export async function resolveNip05Batch(
identifiers: string[],
): Promise<Map<string, string>> {
const results = new Map<string, string>();
await Promise.all(
identifiers.map(async (nip05) => {
const pubkey = await resolveNip05(nip05);
if (pubkey) {
results.set(nip05, pubkey);
}
}),
);
return results;
}

18
src/lib/nostr-utils.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { ProfileContent } from "applesauce-core/helpers";
export function derivePlaceholderName(pubkey: string): string {
return `${pubkey.slice(0, 4)}:${pubkey.slice(-4)}`;
}
export function getDisplayName(
pubkey: string,
metadata?: ProfileContent,
): string {
if (metadata?.display_name) {
return metadata.display_name;
}
if (metadata?.name) {
return metadata.name;
}
return derivePlaceholderName(pubkey);
}

112
src/lib/open-parser.ts Normal file
View File

@@ -0,0 +1,112 @@
import { nip19 } from "nostr-tools";
// Define pointer types locally since they're not exported from nostr-tools
export interface EventPointer {
id: string;
relays?: string[];
author?: string;
}
export interface AddressPointer {
kind: number;
pubkey: string;
identifier: string;
relays?: string[];
}
export interface ParsedOpenCommand {
pointer: EventPointer | AddressPointer;
}
/**
* Parse OPEN command arguments into an event pointer
* Supports:
* - note1... (bech32 note)
* - nevent1... (bech32 nevent with relay hints)
* - naddr1... (bech32 naddr for addressable events)
* - abc123... (64-char hex event ID)
* - kind:pubkey:d-tag (address pointer format)
*/
export function parseOpenCommand(args: string[]): ParsedOpenCommand {
const identifier = args[0];
if (!identifier) {
throw new Error("Event identifier required");
}
// Try bech32 decode first (note, nevent, naddr)
if (
identifier.startsWith("note") ||
identifier.startsWith("nevent") ||
identifier.startsWith("naddr")
) {
try {
const decoded = nip19.decode(identifier);
if (decoded.type === "note") {
// note1... -> EventPointer with just ID
return {
pointer: {
id: decoded.data,
},
};
}
if (decoded.type === "nevent") {
// nevent1... -> EventPointer (already has id and optional relays)
return {
pointer: decoded.data,
};
}
if (decoded.type === "naddr") {
// naddr1... -> AddressPointer (already has kind, pubkey, identifier)
return {
pointer: decoded.data,
};
}
} catch (error) {
throw new Error(`Invalid bech32 identifier: ${error}`);
}
}
// Check if it's a hex event ID (64 chars, hex only)
if (/^[0-9a-f]{64}$/i.test(identifier)) {
return {
pointer: {
id: identifier.toLowerCase(),
},
};
}
// Check if it's an address format (kind:pubkey:d-tag)
if (identifier.includes(":")) {
const parts = identifier.split(":");
if (parts.length >= 2) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2] || "";
if (isNaN(kind)) {
throw new Error("Invalid address format: kind must be a number");
}
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
throw new Error("Invalid address format: pubkey must be 64 hex chars");
}
return {
pointer: {
kind,
pubkey: pubkey.toLowerCase(),
identifier: dTag,
},
};
}
}
throw new Error(
"Invalid event identifier. Supported formats: note1..., nevent1..., naddr1..., hex ID, or kind:pubkey:d-tag",
);
}

62
src/lib/profile-parser.ts Normal file
View File

@@ -0,0 +1,62 @@
import { nip19 } from "nostr-tools";
export interface ParsedProfileCommand {
pubkey: string;
}
/**
* Parse PROFILE command arguments into a pubkey
* Supports:
* - npub1... (bech32 npub)
* - nprofile1... (bech32 nprofile with relay hints)
* - abc123... (64-char hex pubkey)
* - nip05@domain.com (NIP-05 identifier - not implemented yet)
*/
export function parseProfileCommand(args: string[]): ParsedProfileCommand {
const identifier = args[0];
if (!identifier) {
throw new Error("User identifier required");
}
// Try bech32 decode first (npub, nprofile)
if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) {
try {
const decoded = nip19.decode(identifier);
if (decoded.type === "npub") {
// npub1... -> pubkey
return {
pubkey: decoded.data,
};
}
if (decoded.type === "nprofile") {
// nprofile1... -> pubkey (ignore relays for now)
return {
pubkey: decoded.data.pubkey,
};
}
} catch (error) {
throw new Error(`Invalid bech32 identifier: ${error}`);
}
}
// Check if it's a hex pubkey (64 chars, hex only)
if (/^[0-9a-f]{64}$/i.test(identifier)) {
return {
pubkey: identifier.toLowerCase(),
};
}
// Check if it's a NIP-05 identifier (user@domain.com)
if (identifier.includes("@")) {
throw new Error(
"NIP-05 identifier lookup not yet implemented. Please use npub or hex pubkey.",
);
}
throw new Error(
"Invalid user identifier. Supported formats: npub1..., nprofile1..., or hex pubkey",
);
}

303
src/lib/req-parser.ts Normal file
View File

@@ -0,0 +1,303 @@
import { nip19 } from "nostr-tools";
import type { NostrFilter } from "@/types/nostr";
import { isNip05 } from "./nip05";
export interface ParsedReqCommand {
filter: NostrFilter;
relays?: string[];
closeOnEose?: boolean;
nip05Authors?: string[]; // NIP-05 identifiers that need async resolution
nip05PTags?: string[]; // NIP-05 identifiers for #p tags that need async resolution
}
/**
* Parse REQ command arguments into a Nostr filter
* Supports:
* - Filters: -k (kinds), -a (authors), -l (limit), -e (#e), -p (#p), -t (#t), -d (#d)
* - Time: --since, --until
* - Search: --search
* - Relays: wss://relay.com or relay.com (auto-adds wss://)
* - Options: --close-on-eose (close stream after EOSE, default: stream stays open)
*/
export function parseReqCommand(args: string[]): ParsedReqCommand {
const filter: NostrFilter = {};
const relays: string[] = [];
const nip05Authors: string[] = [];
const nip05PTags: string[] = [];
let closeOnEose = false;
let i = 0;
while (i < args.length) {
const arg = args[i];
// Relay URLs (starts with wss://, ws://, or looks like a domain)
if (arg.startsWith("wss://") || arg.startsWith("ws://")) {
relays.push(arg);
i++;
continue;
}
// Shorthand relay (domain-like string without protocol)
if (isRelayDomain(arg)) {
relays.push(`wss://${arg}`);
i++;
continue;
}
// Flags
if (arg.startsWith("-")) {
const flag = arg;
const nextArg = args[i + 1];
switch (flag) {
case "-k":
case "--kind": {
const kind = parseInt(nextArg, 10);
if (!isNaN(kind)) {
if (!filter.kinds) filter.kinds = [];
filter.kinds.push(kind);
i += 2;
} else {
i++;
}
break;
}
case "-a":
case "--author": {
// Check if it's a NIP-05 identifier
if (isNip05(nextArg)) {
nip05Authors.push(nextArg);
i += 2;
} else {
const pubkey = parseNpubOrHex(nextArg);
if (pubkey) {
if (!filter.authors) filter.authors = [];
filter.authors.push(pubkey);
i += 2;
} else {
i++;
}
}
break;
}
case "-l":
case "--limit": {
const limit = parseInt(nextArg, 10);
if (!isNaN(limit)) {
filter.limit = limit;
i += 2;
} else {
i++;
}
break;
}
case "-e": {
const eventId = parseNoteOrHex(nextArg);
if (eventId) {
if (!filter["#e"]) filter["#e"] = [];
filter["#e"].push(eventId);
i += 2;
} else {
i++;
}
break;
}
case "-p": {
// Check if it's a NIP-05 identifier
if (isNip05(nextArg)) {
nip05PTags.push(nextArg);
i += 2;
} else {
const pubkey = parseNpubOrHex(nextArg);
if (pubkey) {
if (!filter["#p"]) filter["#p"] = [];
filter["#p"].push(pubkey);
i += 2;
} else {
i++;
}
}
break;
}
case "-t": {
// Hashtag filter
if (nextArg) {
if (!filter["#t"]) filter["#t"] = [];
filter["#t"].push(nextArg);
i += 2;
} else {
i++;
}
break;
}
case "-d": {
// D-tag filter (for replaceable events)
if (nextArg) {
if (!filter["#d"]) filter["#d"] = [];
filter["#d"].push(nextArg);
i += 2;
} else {
i++;
}
break;
}
case "--since": {
const timestamp = parseTimestamp(nextArg);
if (timestamp) {
filter.since = timestamp;
i += 2;
} else {
i++;
}
break;
}
case "--until": {
const timestamp = parseTimestamp(nextArg);
if (timestamp) {
filter.until = timestamp;
i += 2;
} else {
i++;
}
break;
}
case "--search": {
if (nextArg) {
filter.search = nextArg;
i += 2;
} else {
i++;
}
break;
}
case "--close-on-eose": {
closeOnEose = true;
i++;
break;
}
default:
i++;
break;
}
} else {
i++;
}
}
const result = {
filter,
relays: relays.length > 0 ? relays : undefined,
closeOnEose,
nip05Authors: nip05Authors.length > 0 ? nip05Authors : undefined,
nip05PTags: nip05PTags.length > 0 ? nip05PTags : undefined,
};
console.log("parseReqCommand result:", result);
return result;
}
/**
* Check if a string looks like a relay domain
* Must contain a dot and not be a flag
*/
function isRelayDomain(value: string): boolean {
if (!value || value.startsWith("-")) return false;
// Must contain at least one dot and look like a domain
return /^[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}(:\d+)?(\/.*)?$/.test(value);
}
/**
* Parse timestamp - supports unix timestamp, relative time (1h, 30m, 7d)
*/
function parseTimestamp(value: string): number | null {
if (!value) return null;
// Unix timestamp (10 digits)
if (/^\d{10}$/.test(value)) {
return parseInt(value, 10);
}
// Relative time: 1h, 30m, 7d, 2w
const relativeMatch = value.match(/^(\d+)([smhdw])$/);
if (relativeMatch) {
const amount = parseInt(relativeMatch[1], 10);
const unit = relativeMatch[2];
const now = Math.floor(Date.now() / 1000);
const multipliers: Record<string, number> = {
s: 1,
m: 60,
h: 3600,
d: 86400,
w: 604800,
};
return now - amount * multipliers[unit];
}
return null;
}
/**
* Parse npub or hex pubkey
*/
function parseNpubOrHex(value: string): string | null {
if (!value) return null;
// Try to decode npub
if (value.startsWith("npub")) {
try {
const decoded = nip19.decode(value);
if (decoded.type === "npub") {
return decoded.data;
}
} catch (e) {
// Not valid npub, continue
}
}
// Check if it's hex (64 chars, hex characters)
if (/^[0-9a-f]{64}$/i.test(value)) {
return value.toLowerCase();
}
return null;
}
/**
* Parse note1 or hex event ID
*/
function parseNoteOrHex(value: string): string | null {
if (!value) return null;
// Try to decode note1
if (value.startsWith("note")) {
try {
const decoded = nip19.decode(value);
if (decoded.type === "note") {
return decoded.data;
}
} catch (e) {
// Not valid note, continue
}
}
// Check if it's hex (64 chars, hex characters)
if (/^[0-9a-f]{64}$/i.test(value)) {
return value.toLowerCase();
}
return null;
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

18
src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { EventStoreProvider } from "applesauce-react/providers";
import Root from "./root";
import eventStore from "./services/event-store";
import "./index.css";
import "react-mosaic-component/react-mosaic-component.css";
// Add dark class to html element for default dark theme
document.documentElement.classList.add("dark");
createRoot(document.getElementById("root")!).render(
<StrictMode>
<EventStoreProvider eventStore={eventStore}>
<Root />
</EventStoreProvider>
</StrictMode>,
);

13
src/root.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { createBrowserRouter, RouterProvider } from "react-router";
import Home from "./components/Home";
const router = createBrowserRouter([
{
path: "/",
element: <Home />,
},
]);
export default function Root() {
return <RouterProvider router={router} />;
}

45
src/services/accounts.ts Normal file
View File

@@ -0,0 +1,45 @@
import { AccountManager } from "applesauce-accounts";
import { registerCommonAccountTypes } from "applesauce-accounts/accounts";
const ACCOUNTS = "nostr-accounts";
const ACTIVE_ACCOUNT = "active-account";
function safeParse(s: string) {
try {
return JSON.parse(s);
} catch (err) {
console.error(err);
}
}
const accountManager = new AccountManager();
registerCommonAccountTypes(accountManager);
// load all accounts
if (localStorage.getItem(ACCOUNTS)) {
const accounts = localStorage.getItem(ACCOUNTS);
if (accounts) {
const json = safeParse(accounts);
if (json) accountManager.fromJSON(json);
}
}
// save accounts to localStorage when they change
accountManager.accounts$.subscribe(() => {
localStorage.setItem(ACCOUNTS, JSON.stringify(accountManager.toJSON()));
});
// load active account
const activeAccountId = localStorage.getItem(ACTIVE_ACCOUNT);
// todo: make sure it's part of accounts
if (activeAccountId) {
accountManager.setActive(activeAccountId);
}
// save active to localStorage
accountManager.active$.subscribe((account) => {
if (account) localStorage.setItem(ACTIVE_ACCOUNT, account.id);
else localStorage.removeItem(ACTIVE_ACCOUNT);
});
export default accountManager;

36
src/services/db.ts Normal file
View File

@@ -0,0 +1,36 @@
import { ProfileContent } from "applesauce-core/helpers";
import { Dexie, Table } from "dexie";
export interface Profile extends ProfileContent {
created_at: number;
}
export interface Nip05 {
nip05: string;
pubkey: string;
}
export interface Nip {
id: string;
content: string;
fetchedAt: number;
}
class GrimoireDb extends Dexie {
profiles!: Table<Profile>;
nip05!: Table<Nip05>;
nips!: Table<Nip>;
constructor(name: string) {
super(name);
this.version(3).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
});
}
}
const db = new GrimoireDb("grimoire-dev");
export default db;

View File

@@ -0,0 +1,5 @@
import { EventStore } from "applesauce-core";
const eventStore = new EventStore();
export default eventStore;

37
src/services/loaders.ts Normal file
View File

@@ -0,0 +1,37 @@
import {
createEventLoader,
createAddressLoader,
createTimelineLoader,
} from "applesauce-loaders/loaders";
import pool from "./relay-pool";
import eventStore from "./event-store";
// Aggregator relays for better event discovery
export const AGGREGATOR_RELAYS = [
"wss://relay.nostr.band",
"wss://nos.lol",
"wss://purplepag.es",
"wss://relay.primal.net",
];
// Event loader for fetching single events by ID
export const eventLoader = createEventLoader(pool, {
eventStore,
extraRelays: AGGREGATOR_RELAYS,
});
// Address loader for replaceable events (profiles, relay lists, etc.)
export const addressLoader = createAddressLoader(pool, {
eventStore,
extraRelays: AGGREGATOR_RELAYS,
});
// Profile loader with batching - combines multiple profile requests within 200ms
export const profileLoader = createAddressLoader(pool, {
eventStore,
bufferTime: 200, // Batch requests within 200ms window
extraRelays: AGGREGATOR_RELAYS,
});
// Timeline loader factory - creates loader for event feeds
export { createTimelineLoader };

View File

@@ -0,0 +1,5 @@
import { RelayPool } from "applesauce-relay";
const pool = new RelayPool();
export default pool;

Some files were not shown because too many files have changed in this diff Show More