mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 01:31:11 +02:00
👶
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
165
CLAUDE.md
Normal 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
20
components.json
Normal 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
13
index.html
Normal 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
8650
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
package.json
Normal file
61
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
80
src/components/Command.tsx
Normal file
80
src/components/Command.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/components/CommandLauncher.tsx
Normal file
178
src/components/CommandLauncher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
341
src/components/DecodeViewer.tsx
Normal file
341
src/components/DecodeViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
src/components/EncodeViewer.tsx
Normal file
196
src/components/EncodeViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
src/components/EventDetailViewer.tsx
Normal file
180
src/components/EventDetailViewer.tsx
Normal 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
203
src/components/Home.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
src/components/JsonViewer.tsx
Normal file
68
src/components/JsonViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/components/KindBadge.tsx
Normal file
61
src/components/KindBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/components/KindRenderer.tsx
Normal file
134
src/components/KindRenderer.tsx
Normal 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
108
src/components/ManPage.tsx
Normal 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
160
src/components/Markdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/NipRenderer.tsx
Normal file
57
src/components/NipRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
src/components/ProfileViewer.tsx
Normal file
248
src/components/ProfileViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
src/components/ReqViewer.tsx
Normal file
249
src/components/ReqViewer.tsx
Normal 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
40
src/components/TabBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/components/Timestamp.tsx
Normal file
11
src/components/Timestamp.tsx
Normal 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;
|
||||
}
|
||||
99
src/components/WinViewer.tsx
Normal file
99
src/components/WinViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/WindowToolbar.tsx
Normal file
23
src/components/WindowToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
src/components/command-launcher.css
Normal file
227
src/components/command-launcher.css
Normal 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);
|
||||
}
|
||||
79
src/components/nostr/EmbeddedEvent.tsx
Normal file
79
src/components/nostr/EmbeddedEvent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/nostr/Feed.tsx
Normal file
40
src/components/nostr/Feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/nostr/LinkPreview/AudioLink.tsx
Normal file
18
src/components/nostr/LinkPreview/AudioLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/nostr/LinkPreview/ImageLink.tsx
Normal file
18
src/components/nostr/LinkPreview/ImageLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/nostr/LinkPreview/PlainLink.tsx
Normal file
19
src/components/nostr/LinkPreview/PlainLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/nostr/LinkPreview/VideoLink.tsx
Normal file
18
src/components/nostr/LinkPreview/VideoLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
src/components/nostr/LinkPreview/index.ts
Normal file
4
src/components/nostr/LinkPreview/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ImageLink } from "./ImageLink";
|
||||
export { VideoLink } from "./VideoLink";
|
||||
export { AudioLink } from "./AudioLink";
|
||||
export { PlainLink } from "./PlainLink";
|
||||
128
src/components/nostr/MediaDialog.tsx
Normal file
128
src/components/nostr/MediaDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
src/components/nostr/MediaEmbed.tsx
Normal file
199
src/components/nostr/MediaEmbed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/nostr/RichText.tsx
Normal file
67
src/components/nostr/RichText.tsx
Normal 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;
|
||||
}
|
||||
17
src/components/nostr/RichText/Emoji.tsx
Normal file
17
src/components/nostr/RichText/Emoji.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
src/components/nostr/RichText/EventEmbed.tsx
Normal file
58
src/components/nostr/RichText/EventEmbed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/nostr/RichText/Gallery.tsx
Normal file
67
src/components/nostr/RichText/Gallery.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
src/components/nostr/RichText/Hashtag.tsx
Normal file
17
src/components/nostr/RichText/Hashtag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/nostr/RichText/Link.tsx
Normal file
70
src/components/nostr/RichText/Link.tsx
Normal 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} />;
|
||||
}
|
||||
60
src/components/nostr/RichText/Mention.tsx
Normal file
60
src/components/nostr/RichText/Mention.tsx
Normal 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;
|
||||
}
|
||||
9
src/components/nostr/RichText/Text.tsx
Normal file
9
src/components/nostr/RichText/Text.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
interface TextNodeProps {
|
||||
node: {
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function Text({ node }: TextNodeProps) {
|
||||
return <>{node.value}</>;
|
||||
}
|
||||
6
src/components/nostr/RichText/index.ts
Normal file
6
src/components/nostr/RichText/index.ts
Normal 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";
|
||||
35
src/components/nostr/UserName.tsx
Normal file
35
src/components/nostr/UserName.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/components/nostr/index.ts
Normal file
3
src/components/nostr/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Export all Nostr-specific components
|
||||
export { UserName } from "./UserName";
|
||||
export { RichText } from "./RichText";
|
||||
161
src/components/nostr/kinds/BaseEventRenderer.tsx
Normal file
161
src/components/nostr/kinds/BaseEventRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
src/components/nostr/kinds/BaseEventRenderer.tsx.backup
Normal file
161
src/components/nostr/kinds/BaseEventRenderer.tsx.backup
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/components/nostr/kinds/Kind0DetailRenderer.tsx
Normal file
10
src/components/nostr/kinds/Kind0DetailRenderer.tsx
Normal 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} />;
|
||||
}
|
||||
62
src/components/nostr/kinds/Kind0Renderer.tsx
Normal file
62
src/components/nostr/kinds/Kind0Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/components/nostr/kinds/Kind1Renderer.tsx
Normal file
13
src/components/nostr/kinds/Kind1Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
src/components/nostr/kinds/Kind30023DetailRenderer.tsx
Normal file
247
src/components/nostr/kinds/Kind30023DetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/nostr/kinds/Kind30023Renderer.tsx
Normal file
40
src/components/nostr/kinds/Kind30023Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/nostr/kinds/Kind6Renderer.tsx
Normal file
40
src/components/nostr/kinds/Kind6Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src/components/nostr/kinds/Kind7Renderer.tsx
Normal file
150
src/components/nostr/kinds/Kind7Renderer.tsx
Normal 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} />;
|
||||
}
|
||||
111
src/components/nostr/kinds/Kind9735Renderer.tsx
Normal file
111
src/components/nostr/kinds/Kind9735Renderer.tsx
Normal 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} />;
|
||||
}
|
||||
136
src/components/nostr/kinds/Kind9802DetailRenderer.tsx
Normal file
136
src/components/nostr/kinds/Kind9802DetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/nostr/kinds/Kind9802Renderer.tsx
Normal file
54
src/components/nostr/kinds/Kind9802Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
src/components/nostr/kinds/README.md
Normal file
137
src/components/nostr/kinds/README.md
Normal 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
|
||||
71
src/components/nostr/kinds/index.tsx
Normal file
71
src/components/nostr/kinds/index.tsx
Normal 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";
|
||||
25
src/components/nostr/nip05.tsx
Normal file
25
src/components/nostr/nip05.tsx
Normal 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} />;
|
||||
}
|
||||
14
src/components/nostr/npub.tsx
Normal file
14
src/components/nostr/npub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/nostr/relay-pool.tsx
Normal file
30
src/components/nostr/relay-pool.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
src/components/nostr/user-menu.tsx
Normal file
170
src/components/nostr/user-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal 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 }
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal 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 }
|
||||
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
27
src/components/ui/hover-card.tsx
Normal file
27
src/components/ui/hover-card.tsx
Normal 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 };
|
||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal 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 }
|
||||
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal 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
919
src/constants/kinds.ts
Normal 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
203
src/constants/nips.ts
Normal 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
254
src/core/logic.ts
Normal 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
111
src/core/state.ts
Normal 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
104
src/hooks/useAccountSync.ts
Normal 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
72
src/hooks/useNip.ts
Normal 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
20
src/hooks/useNip05.ts
Normal 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
124
src/hooks/useNostrEvent.ts
Normal 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
32
src/hooks/useProfile.ts
Normal 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
128
src/hooks/useReqTimeline.ts
Normal 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
80
src/hooks/useTimeline.ts
Normal 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
225
src/index.css
Normal 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
104
src/lib/decode-parser.ts
Normal 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
179
src/lib/encode-parser.ts
Normal 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
17
src/lib/nip-kinds.ts
Normal 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
59
src/lib/nip05.ts
Normal 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
18
src/lib/nostr-utils.ts
Normal 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
112
src/lib/open-parser.ts
Normal 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
62
src/lib/profile-parser.ts
Normal 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
303
src/lib/req-parser.ts
Normal 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
6
src/lib/utils.ts
Normal 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
18
src/main.tsx
Normal 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
13
src/root.tsx
Normal 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
45
src/services/accounts.ts
Normal 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
36
src/services/db.ts
Normal 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;
|
||||
5
src/services/event-store.ts
Normal file
5
src/services/event-store.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { EventStore } from "applesauce-core";
|
||||
|
||||
const eventStore = new EventStore();
|
||||
|
||||
export default eventStore;
|
||||
37
src/services/loaders.ts
Normal file
37
src/services/loaders.ts
Normal 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 };
|
||||
5
src/services/relay-pool.ts
Normal file
5
src/services/relay-pool.ts
Normal 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
Reference in New Issue
Block a user