mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
feat: RTL support
This commit is contained in:
165
CLAUDE.md
165
CLAUDE.md
@@ -1,165 +0,0 @@
|
||||
# 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
|
||||
@@ -4,7 +4,13 @@
|
||||
<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>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Oxygen+Mono&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>grimoire - a nostr client for magicians</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getKindInfo } from "@/constants/kinds";
|
||||
import { KindBadge } from "./KindBadge";
|
||||
import Command from "./Command";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
@@ -25,16 +24,13 @@ export default function KindRenderer({ kind }: { kind: number }) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center 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 className="w-14 h-14 bg-accent/20 rounded flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="w-8 h-8 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>
|
||||
@@ -86,7 +82,7 @@ export default function KindRenderer({ kind }: { kind: number }) {
|
||||
<a
|
||||
href={`https://github.com/nostr-protocol/nips/blob/master/${kindInfo.nip.padStart(
|
||||
2,
|
||||
"0"
|
||||
"0",
|
||||
)}.md`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Components } from "react-markdown";
|
||||
import { useMemo } from "react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { MediaEmbed } from "@/components/nostr/MediaEmbed";
|
||||
|
||||
@@ -11,146 +12,212 @@ interface MarkdownProps {
|
||||
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"
|
||||
const components: Components = useMemo(
|
||||
() => ({
|
||||
// Headings
|
||||
h1: ({ children }) => (
|
||||
<h1
|
||||
dir="auto"
|
||||
className="text-lg font-bold mt-4 mb-3 first:mt-0 text-start"
|
||||
>
|
||||
{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">
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2
|
||||
dir="auto"
|
||||
className="text-base font-bold mt-4 mb-2 first:mt-0 text-start"
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className="block bg-muted p-3 rounded-lg my-3 overflow-x-auto text-xs leading-relaxed max-w-full">
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3
|
||||
dir="auto"
|
||||
className="text-sm font-bold mt-3 mb-2 first:mt-0 text-start"
|
||||
>
|
||||
{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">
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4
|
||||
dir="auto"
|
||||
className="text-sm font-bold mt-3 mb-2 first:mt-0 text-start"
|
||||
>
|
||||
{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>
|
||||
),
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children }) => (
|
||||
<h5
|
||||
dir="auto"
|
||||
className="text-xs font-bold mt-2 mb-1 first:mt-0 text-start"
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children }) => (
|
||||
<h6
|
||||
dir="auto"
|
||||
className="text-xs font-bold mt-2 mb-1 first:mt-0 text-start"
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
|
||||
// Images - Inline with zoom
|
||||
img: ({ src, alt }) =>
|
||||
src ? (
|
||||
<MediaEmbed
|
||||
url={src}
|
||||
alt={alt}
|
||||
preset="preview"
|
||||
enableZoom
|
||||
className="my-3"
|
||||
/>
|
||||
) : null,
|
||||
// Paragraphs and text
|
||||
p: ({ children }) => (
|
||||
<p
|
||||
dir="auto"
|
||||
className="mb-3 leading-relaxed text-sm last:mb-0 break-words text-start"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Emphasis
|
||||
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
};
|
||||
// Links
|
||||
a: ({ href, children }) => {
|
||||
console.log("[Markdown Link]", { href, children });
|
||||
|
||||
// Check if it's a relative NIP link (e.g., "./01.md" or "01.md" or "30.md")
|
||||
if (href && (href.endsWith(".md") || href.includes(".md#"))) {
|
||||
console.log("[Markdown] Detected .md link:", href);
|
||||
|
||||
// Extract NIP number from various formats (1-3 digits)
|
||||
const nipMatch = href.match(/(\d{1,3})\.md/);
|
||||
console.log("[Markdown] Regex match result:", nipMatch);
|
||||
|
||||
if (nipMatch) {
|
||||
const nipNumber = nipMatch[1];
|
||||
console.log("[Markdown] Creating NIP link for NIP-" + nipNumber);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`#nip-${nipNumber}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("[Markdown] NIP link clicked! NIP-" + nipNumber);
|
||||
console.log("[Markdown] Calling addWindow directly");
|
||||
addWindow("nip", { number: nipNumber }, `NIP ${nipNumber}`);
|
||||
console.log("[Markdown] addWindow called");
|
||||
}}
|
||||
className="text-accent underline decoration-dotted cursor-pointer hover:text-accent/80 transition-colors"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular external link
|
||||
console.log("[Markdown] Creating regular external link for:", href);
|
||||
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
|
||||
dir="auto"
|
||||
className="list-disc list-inside mb-3 space-y-1 text-sm text-start"
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol
|
||||
dir="auto"
|
||||
className="list-decimal list-inside mb-3 space-y-1 text-sm text-start"
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li dir="auto" className="leading-relaxed text-start">
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
dir="auto"
|
||||
className="border-s-4 border-muted-foreground/30 ps-3 py-2 my-3 italic text-muted-foreground text-sm text-start"
|
||||
>
|
||||
{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>,
|
||||
}),
|
||||
[addWindow],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { useRenderedContent } from "applesauce-react/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Hooks } from "applesauce-react";
|
||||
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";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
const { useRenderedContent } = Hooks;
|
||||
|
||||
interface RichTextProps {
|
||||
event?: NostrEvent;
|
||||
@@ -32,15 +34,15 @@ const contentComponents = {
|
||||
export function RichText({ event, content, className = "" }: RichTextProps) {
|
||||
// If plain content is provided, just render it
|
||||
if (content && !event) {
|
||||
const lines = content.trim().split("\n");
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-pre-line leading-tight break-words",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content.trim()}
|
||||
</span>
|
||||
<div className={cn("leading-tight break-words", className)}>
|
||||
{lines.map((line, idx) => (
|
||||
<div key={idx} dir="auto">
|
||||
{line || "\u00A0"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,14 +54,9 @@ export function RichText({ event, content, className = "" }: RichTextProps) {
|
||||
};
|
||||
const renderedContent = useRenderedContent(trimmedEvent, contentComponents);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-pre-line leading-tight break-words",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("leading-tight break-words", className)}>
|
||||
{renderedContent}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import type { TextNode } from "applesauce-content";
|
||||
|
||||
interface TextNodeProps {
|
||||
node: {
|
||||
type: "text";
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function Text({ node }: TextNodeProps) {
|
||||
return <>{node.value}</>;
|
||||
// Check if text contains RTL characters (Arabic, Hebrew, Persian, etc.)
|
||||
function hasRTLCharacters(text: string): boolean {
|
||||
return /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/.test(text);
|
||||
}
|
||||
|
||||
export function Text({ node }: TextNodeProps) {
|
||||
const text = node.value;
|
||||
|
||||
// If no newlines, render as inline span
|
||||
if (!text.includes("\n")) {
|
||||
const isRTL = hasRTLCharacters(text);
|
||||
return <span dir={isRTL ? "rtl" : "auto"}>{text || "\u00A0"}</span>;
|
||||
}
|
||||
|
||||
// If has newlines, use divs for proper RTL per line
|
||||
const lines = text.split("\n");
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, idx) => {
|
||||
const isRTL = hasRTLCharacters(line);
|
||||
return (
|
||||
<div key={idx} dir={isRTL ? "rtl" : "auto"}>
|
||||
{line || "\u00A0"}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) {
|
||||
|
||||
return (
|
||||
<span
|
||||
dir="auto"
|
||||
className={cn("cursor-pointer hover:underline", className)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
||||
115
src/components/nostr/kinds/Kind1063Renderer.tsx
Normal file
115
src/components/nostr/kinds/Kind1063Renderer.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { RichText } from "../RichText";
|
||||
import {
|
||||
parseFileMetadata,
|
||||
isImageMime,
|
||||
isVideoMime,
|
||||
isAudioMime,
|
||||
formatFileSize,
|
||||
} from "@/lib/imeta";
|
||||
import { FileText, Download } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1063 - File Metadata (NIP-94)
|
||||
* Displays file metadata with appropriate preview for images, videos, and audio
|
||||
*/
|
||||
export function Kind1063Renderer({ event, showTimestamp }: BaseEventProps) {
|
||||
const metadata = parseFileMetadata(event);
|
||||
|
||||
// Determine file type from MIME
|
||||
const isImage = isImageMime(metadata.m);
|
||||
const isVideo = isVideoMime(metadata.m);
|
||||
const isAudio = isAudioMime(metadata.m);
|
||||
|
||||
// Get additional metadata
|
||||
const fileName =
|
||||
event.tags.find((t) => t[0] === "name")?.[1] || "Unknown file";
|
||||
const summary =
|
||||
event.tags.find((t) => t[0] === "summary")?.[1] || event.content;
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* File preview */}
|
||||
{metadata.url && (isImage || isVideo || isAudio) ? (
|
||||
<div>
|
||||
{isImage && (
|
||||
<MediaEmbed
|
||||
url={metadata.url}
|
||||
type="image"
|
||||
alt={metadata.alt || fileName}
|
||||
preset="preview"
|
||||
enableZoom
|
||||
/>
|
||||
)}
|
||||
{isVideo && (
|
||||
<MediaEmbed
|
||||
url={metadata.url}
|
||||
type="video"
|
||||
preset="preview"
|
||||
showControls
|
||||
/>
|
||||
)}
|
||||
{isAudio && (
|
||||
<MediaEmbed url={metadata.url} type="audio" showControls />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Non-media file preview */
|
||||
<div className="flex items-center gap-3 p-4 border border-border rounded-lg bg-muted/20">
|
||||
<FileText className="w-8 h-8 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{fileName}</p>
|
||||
{metadata.m && (
|
||||
<p className="text-xs text-muted-foreground">{metadata.m}</p>
|
||||
)}
|
||||
</div>
|
||||
{metadata.url && (
|
||||
<a
|
||||
href={metadata.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File metadata */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
{metadata.m && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<code className="font-mono text-xs">{metadata.m}</code>
|
||||
</>
|
||||
)}
|
||||
{metadata.size && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Size</span>
|
||||
<span>{formatFileSize(metadata.size)}</span>
|
||||
</>
|
||||
)}
|
||||
{metadata.dim && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Dimensions</span>
|
||||
<span>{metadata.dim}</span>
|
||||
</>
|
||||
)}
|
||||
{metadata.x && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Hash</span>
|
||||
<code className="font-mono text-xs truncate">{metadata.x}</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description/Summary */}
|
||||
{summary && <RichText content={summary} className="text-sm" />}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
47
src/components/nostr/kinds/Kind20Renderer.tsx
Normal file
47
src/components/nostr/kinds/Kind20Renderer.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { RichText } from "../RichText";
|
||||
import { parseImetaTags } from "@/lib/imeta";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 20 - Picture Event (NIP-68)
|
||||
* Picture-first feed events with imeta tags for image metadata
|
||||
*/
|
||||
export function Kind20Renderer({ event, showTimestamp }: BaseEventProps) {
|
||||
// Parse imeta tags to get image URLs and metadata
|
||||
const images = parseImetaTags(event);
|
||||
|
||||
// Get title from tags
|
||||
const title = event.tags.find((t) => t[0] === "title")?.[1];
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title if present */}
|
||||
{title && (
|
||||
<h3 dir="auto" className="text-base font-semibold text-start">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Images */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{images.map((img, i) => (
|
||||
<MediaEmbed
|
||||
key={i}
|
||||
url={img.url}
|
||||
alt={img.alt || title || "Picture"}
|
||||
preset="preview"
|
||||
enableZoom
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.content && <RichText event={event} className="text-sm" />}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
38
src/components/nostr/kinds/Kind21Renderer.tsx
Normal file
38
src/components/nostr/kinds/Kind21Renderer.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { RichText } from "../RichText";
|
||||
import { parseImetaTags } from "@/lib/imeta";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 21 - Video Event (NIP-71)
|
||||
* Horizontal/landscape video events with imeta tags
|
||||
*/
|
||||
export function Kind21Renderer({ event, showTimestamp }: BaseEventProps) {
|
||||
// Parse imeta tags to get video URLs and metadata
|
||||
const videos = parseImetaTags(event);
|
||||
|
||||
// Get title from tags
|
||||
const title = event.tags.find((t) => t[0] === "title")?.[1];
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title if present */}
|
||||
{title && <h3 className="text-base font-semibold">{title}</h3>}
|
||||
|
||||
{/* Video - use first video from imeta or preview image if video fails */}
|
||||
{videos.length > 0 && (
|
||||
<MediaEmbed
|
||||
url={videos[0].url}
|
||||
type="video"
|
||||
preset="preview"
|
||||
showControls
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.content && <RichText event={event} className="text-sm" />}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
38
src/components/nostr/kinds/Kind22Renderer.tsx
Normal file
38
src/components/nostr/kinds/Kind22Renderer.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { RichText } from "../RichText";
|
||||
import { parseImetaTags } from "@/lib/imeta";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 22 - Short Video Event (NIP-71)
|
||||
* Short-form portrait video events (like TikTok/Reels)
|
||||
*/
|
||||
export function Kind22Renderer({ event, showTimestamp }: BaseEventProps) {
|
||||
// Parse imeta tags to get video URLs and metadata
|
||||
const videos = parseImetaTags(event);
|
||||
|
||||
// Get title from tags
|
||||
const title = event.tags.find((t) => t[0] === "title")?.[1];
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event} showTimestamp={showTimestamp}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title if present */}
|
||||
{title && <h3 className="text-base font-semibold">{title}</h3>}
|
||||
|
||||
{/* Short video - optimized for portrait */}
|
||||
{videos.length > 0 && (
|
||||
<MediaEmbed
|
||||
url={videos[0].url}
|
||||
type="video"
|
||||
preset="preview"
|
||||
showControls
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.content && <RichText event={event} className="text-sm" />}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function Kind7Renderer({ event, showTimestamp }: BaseEventProps) {
|
||||
// Parse reaction content to detect custom emoji shortcodes
|
||||
// Format: :shortcode: in the content
|
||||
const parsedReaction = useMemo(() => {
|
||||
const match = reaction.match(/^:([a-zA-Z0-9_-]+):$/);
|
||||
const match = reaction.match(/^:([a-zA-Z0-9_#-]+):$/);
|
||||
if (match && customEmojis[match[1]]) {
|
||||
return {
|
||||
type: "custom" as const,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { BaseEventProps } from "./BaseEventRenderer";
|
||||
import { Kind0Renderer } from "./Kind0Renderer";
|
||||
import { Kind1Renderer } from "./Kind1Renderer";
|
||||
import { Kind6Renderer } from "./Kind6Renderer";
|
||||
import { Kind7Renderer } from "./Kind7Renderer";
|
||||
import { Kind20Renderer } from "./Kind20Renderer";
|
||||
import { Kind21Renderer } from "./Kind21Renderer";
|
||||
import { Kind22Renderer } from "./Kind22Renderer";
|
||||
import { Kind1063Renderer } from "./Kind1063Renderer";
|
||||
import { Kind9735Renderer } from "./Kind9735Renderer";
|
||||
import { Kind9802Renderer } from "./Kind9802Renderer";
|
||||
import { Kind30023Renderer } from "./Kind30023Renderer";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { BaseEventContainer } from "./BaseEventRenderer";
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
|
||||
/**
|
||||
* Registry of kind-specific renderers
|
||||
@@ -18,6 +21,10 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
1: Kind1Renderer, // Short Text Note
|
||||
6: Kind6Renderer, // Repost
|
||||
7: Kind7Renderer, // Reaction
|
||||
20: Kind20Renderer, // Picture (NIP-68)
|
||||
21: Kind21Renderer, // Video Event (NIP-71)
|
||||
22: Kind22Renderer, // Short Video (NIP-71)
|
||||
1063: Kind1063Renderer, // File Metadata (NIP-94)
|
||||
1111: Kind1Renderer, // Post
|
||||
9735: Kind9735Renderer, // Zap Receipt
|
||||
9802: Kind9802Renderer, // Highlight
|
||||
@@ -68,4 +75,8 @@ export type { BaseEventProps } from "./BaseEventRenderer";
|
||||
export { Kind1Renderer } from "./Kind1Renderer";
|
||||
export { Kind6Renderer } from "./Kind6Renderer";
|
||||
export { Kind7Renderer } from "./Kind7Renderer";
|
||||
export { Kind20Renderer } from "./Kind20Renderer";
|
||||
export { Kind21Renderer } from "./Kind21Renderer";
|
||||
export { Kind22Renderer } from "./Kind22Renderer";
|
||||
export { Kind1063Renderer } from "./Kind1063Renderer";
|
||||
export { Kind9735Renderer } from "./Kind9735Renderer";
|
||||
|
||||
@@ -53,10 +53,6 @@ export default function UserMenu() {
|
||||
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(
|
||||
|
||||
@@ -219,6 +219,11 @@ export const setActiveAccount = (
|
||||
state: GrimoireState,
|
||||
pubkey: string | undefined,
|
||||
): GrimoireState => {
|
||||
// If pubkey is already set to the same value, return state unchanged
|
||||
if (state.activeAccount?.pubkey === pubkey) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
return {
|
||||
...state,
|
||||
@@ -244,6 +249,12 @@ export const setActiveAccountRelays = (
|
||||
if (!state.activeAccount) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// If relays reference hasn't changed, return state unchanged
|
||||
if (state.activeAccount.relays === relays) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeAccount: {
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 { setActiveAccount, setActiveAccountRelays } = useGrimoire();
|
||||
const eventStore = useEventStore();
|
||||
|
||||
// Watch active account from accounts service
|
||||
@@ -18,25 +18,17 @@ export function useAccountSync() {
|
||||
|
||||
// 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]);
|
||||
setActiveAccount(activeAccount?.pubkey);
|
||||
}, [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);
|
||||
let lastRelayEventId: string | undefined;
|
||||
|
||||
// Subscribe to kind 10002 (relay list)
|
||||
const subscription = addressLoader({
|
||||
@@ -49,12 +41,12 @@ export function useAccountSync() {
|
||||
const storeSubscription = eventStore
|
||||
.replaceable(10002, pubkey, "")
|
||||
.subscribe((relayListEvent) => {
|
||||
console.log(
|
||||
"useAccountSync: relay list event received",
|
||||
relayListEvent,
|
||||
);
|
||||
if (!relayListEvent) return;
|
||||
|
||||
// Only process if this is a new event
|
||||
if (relayListEvent.id === lastRelayEventId) return;
|
||||
lastRelayEventId = relayListEvent.id;
|
||||
|
||||
// Parse inbox and outbox relays
|
||||
const inboxRelays = getInboxes(relayListEvent);
|
||||
const outboxRelays = getOutboxes(relayListEvent);
|
||||
@@ -92,7 +84,6 @@ export function useAccountSync() {
|
||||
all: allRelays,
|
||||
};
|
||||
|
||||
console.log("useAccountSync: parsed relays", relays);
|
||||
setActiveAccountRelays(relays);
|
||||
});
|
||||
|
||||
@@ -100,5 +91,5 @@ export function useAccountSync() {
|
||||
subscription.unsubscribe();
|
||||
storeSubscription.unsubscribe();
|
||||
};
|
||||
}, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]);
|
||||
}, [activeAccount?.pubkey, eventStore]);
|
||||
}
|
||||
|
||||
158
src/lib/imeta.ts
Normal file
158
src/lib/imeta.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Utilities for parsing imeta tags (NIP-92) and file metadata tags (NIP-94)
|
||||
*/
|
||||
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
export interface ImetaEntry {
|
||||
url: string;
|
||||
m?: string; // MIME type
|
||||
blurhash?: string;
|
||||
dim?: string; // dimensions (e.g., "1920x1080")
|
||||
alt?: string; // alt text
|
||||
x?: string; // SHA-256 hash
|
||||
size?: string; // file size in bytes
|
||||
fallback?: string[]; // fallback URLs
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an imeta tag into structured data
|
||||
* Format: ["imeta", "url https://...", "m image/jpeg", "blurhash U...]
|
||||
*/
|
||||
export function parseImetaTag(tag: string[]): ImetaEntry | null {
|
||||
if (tag[0] !== "imeta" || tag.length < 2) return null;
|
||||
|
||||
const entry: Partial<ImetaEntry> = {};
|
||||
|
||||
// Parse each key-value pair
|
||||
for (let i = 1; i < tag.length; i++) {
|
||||
const parts = tag[i].split(" ");
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const key = parts[0];
|
||||
const value = parts.slice(1).join(" ");
|
||||
|
||||
if (key === "url") {
|
||||
entry.url = value;
|
||||
} else if (key === "fallback") {
|
||||
if (!entry.fallback) entry.fallback = [];
|
||||
entry.fallback.push(value);
|
||||
} else {
|
||||
(entry as any)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// URL is required
|
||||
if (!entry.url) return null;
|
||||
|
||||
return entry as ImetaEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all imeta tags from an event
|
||||
*/
|
||||
export function parseImetaTags(event: NostrEvent): ImetaEntry[] {
|
||||
return event.tags
|
||||
.filter((tag) => tag[0] === "imeta")
|
||||
.map(parseImetaTag)
|
||||
.filter((entry): entry is ImetaEntry => entry !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse file metadata from NIP-94 kind 1063 event tags
|
||||
*/
|
||||
export function parseFileMetadata(event: NostrEvent): ImetaEntry {
|
||||
const metadata: Partial<ImetaEntry> = {};
|
||||
|
||||
for (const tag of event.tags) {
|
||||
const [key, value] = tag;
|
||||
if (!value) continue;
|
||||
|
||||
switch (key) {
|
||||
case "url":
|
||||
metadata.url = value;
|
||||
break;
|
||||
case "m":
|
||||
metadata.m = value;
|
||||
break;
|
||||
case "x":
|
||||
metadata.x = value;
|
||||
break;
|
||||
case "size":
|
||||
metadata.size = value;
|
||||
break;
|
||||
case "dim":
|
||||
metadata.dim = value;
|
||||
break;
|
||||
case "blurhash":
|
||||
metadata.blurhash = value;
|
||||
break;
|
||||
case "alt":
|
||||
metadata.alt = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata as ImetaEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary image URL from a picture event (kind 20)
|
||||
* Tries imeta tags first, then falls back to content
|
||||
*/
|
||||
export function getPictureUrl(event: NostrEvent): string | null {
|
||||
// Try imeta tags first
|
||||
const imeta = parseImetaTags(event);
|
||||
if (imeta.length > 0 && imeta[0].url) {
|
||||
return imeta[0].url;
|
||||
}
|
||||
|
||||
// Fallback: try to extract URL from content
|
||||
const urlMatch = event.content.match(/https?:\/\/[^\s]+/);
|
||||
return urlMatch ? urlMatch[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type is an image
|
||||
*/
|
||||
export function isImageMime(mime?: string): boolean {
|
||||
if (!mime) return false;
|
||||
return mime.startsWith("image/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type is a video
|
||||
*/
|
||||
export function isVideoMime(mime?: string): boolean {
|
||||
if (!mime) return false;
|
||||
return mime.startsWith("video/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type is audio
|
||||
*/
|
||||
export function isAudioMime(mime?: string): boolean {
|
||||
if (!mime) return false;
|
||||
return mime.startsWith("audio/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
export function formatFileSize(bytes?: string | number): string {
|
||||
if (!bytes) return "Unknown size";
|
||||
|
||||
const size = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
|
||||
if (isNaN(size)) return "Unknown size";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let unitIndex = 0;
|
||||
let displaySize = size;
|
||||
|
||||
while (displaySize >= 1024 && unitIndex < units.length - 1) {
|
||||
displaySize /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${displaySize.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.tsx","./src/root.tsx","./src/vite-env.d.ts","./src/components/command.tsx","./src/components/commandlauncher.tsx","./src/components/decodeviewer.tsx","./src/components/encodeviewer.tsx","./src/components/eventdetailviewer.tsx","./src/components/home.tsx","./src/components/jsonviewer.tsx","./src/components/kindbadge.tsx","./src/components/kindrenderer.tsx","./src/components/manpage.tsx","./src/components/markdown.tsx","./src/components/niprenderer.tsx","./src/components/profileviewer.tsx","./src/components/reqviewer.tsx","./src/components/tabbar.tsx","./src/components/timestamp.tsx","./src/components/winviewer.tsx","./src/components/windowtoolbar.tsx","./src/components/nostr/embeddedevent.tsx","./src/components/nostr/feed.tsx","./src/components/nostr/mediadialog.tsx","./src/components/nostr/mediaembed.tsx","./src/components/nostr/richtext.tsx","./src/components/nostr/username.tsx","./src/components/nostr/index.ts","./src/components/nostr/nip05.tsx","./src/components/nostr/npub.tsx","./src/components/nostr/relay-pool.tsx","./src/components/nostr/user-menu.tsx","./src/components/nostr/linkpreview/audiolink.tsx","./src/components/nostr/linkpreview/imagelink.tsx","./src/components/nostr/linkpreview/plainlink.tsx","./src/components/nostr/linkpreview/videolink.tsx","./src/components/nostr/linkpreview/index.ts","./src/components/nostr/richtext/emoji.tsx","./src/components/nostr/richtext/eventembed.tsx","./src/components/nostr/richtext/gallery.tsx","./src/components/nostr/richtext/hashtag.tsx","./src/components/nostr/richtext/link.tsx","./src/components/nostr/richtext/mention.tsx","./src/components/nostr/richtext/text.tsx","./src/components/nostr/richtext/index.ts","./src/components/nostr/kinds/baseeventrenderer.tsx","./src/components/nostr/kinds/kind0detailrenderer.tsx","./src/components/nostr/kinds/kind0renderer.tsx","./src/components/nostr/kinds/kind1renderer.tsx","./src/components/nostr/kinds/kind30023detailrenderer.tsx","./src/components/nostr/kinds/kind30023renderer.tsx","./src/components/nostr/kinds/kind6renderer.tsx","./src/components/nostr/kinds/kind7renderer.tsx","./src/components/nostr/kinds/kind9735renderer.tsx","./src/components/nostr/kinds/kind9802detailrenderer.tsx","./src/components/nostr/kinds/kind9802renderer.tsx","./src/components/nostr/kinds/index.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/constants/kinds.ts","./src/constants/nips.ts","./src/core/logic.ts","./src/core/state.ts","./src/hooks/useaccountsync.ts","./src/hooks/usenip.ts","./src/hooks/usenip05.ts","./src/hooks/usenostrevent.ts","./src/hooks/useprofile.ts","./src/hooks/usereqtimeline.ts","./src/hooks/usetimeline.ts","./src/lib/decode-parser.ts","./src/lib/encode-parser.ts","./src/lib/nip-kinds.ts","./src/lib/nip05.ts","./src/lib/nostr-utils.ts","./src/lib/open-parser.ts","./src/lib/profile-parser.ts","./src/lib/req-parser.ts","./src/lib/utils.ts","./src/services/accounts.ts","./src/services/db.ts","./src/services/event-store.ts","./src/services/loaders.ts","./src/services/relay-pool.ts","./src/types/app.ts","./src/types/man.ts","./src/types/nostr.ts","./src/types/profile.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/main.tsx","./src/root.tsx","./src/vite-env.d.ts","./src/components/command.tsx","./src/components/commandlauncher.tsx","./src/components/decodeviewer.tsx","./src/components/encodeviewer.tsx","./src/components/eventdetailviewer.tsx","./src/components/home.tsx","./src/components/jsonviewer.tsx","./src/components/kindbadge.tsx","./src/components/kindrenderer.tsx","./src/components/manpage.tsx","./src/components/markdown.tsx","./src/components/niprenderer.tsx","./src/components/profileviewer.tsx","./src/components/reqviewer.tsx","./src/components/tabbar.tsx","./src/components/timestamp.tsx","./src/components/winviewer.tsx","./src/components/windowtoolbar.tsx","./src/components/nostr/embeddedevent.tsx","./src/components/nostr/feed.tsx","./src/components/nostr/mediadialog.tsx","./src/components/nostr/mediaembed.tsx","./src/components/nostr/richtext.tsx","./src/components/nostr/username.tsx","./src/components/nostr/index.ts","./src/components/nostr/nip05.tsx","./src/components/nostr/npub.tsx","./src/components/nostr/relay-pool.tsx","./src/components/nostr/user-menu.tsx","./src/components/nostr/linkpreview/audiolink.tsx","./src/components/nostr/linkpreview/imagelink.tsx","./src/components/nostr/linkpreview/plainlink.tsx","./src/components/nostr/linkpreview/videolink.tsx","./src/components/nostr/linkpreview/index.ts","./src/components/nostr/richtext/emoji.tsx","./src/components/nostr/richtext/eventembed.tsx","./src/components/nostr/richtext/gallery.tsx","./src/components/nostr/richtext/hashtag.tsx","./src/components/nostr/richtext/link.tsx","./src/components/nostr/richtext/mention.tsx","./src/components/nostr/richtext/text.tsx","./src/components/nostr/richtext/index.ts","./src/components/nostr/kinds/baseeventrenderer.tsx","./src/components/nostr/kinds/kind0detailrenderer.tsx","./src/components/nostr/kinds/kind0renderer.tsx","./src/components/nostr/kinds/kind1063renderer.tsx","./src/components/nostr/kinds/kind1renderer.tsx","./src/components/nostr/kinds/kind20renderer.tsx","./src/components/nostr/kinds/kind21renderer.tsx","./src/components/nostr/kinds/kind22renderer.tsx","./src/components/nostr/kinds/kind30023detailrenderer.tsx","./src/components/nostr/kinds/kind30023renderer.tsx","./src/components/nostr/kinds/kind6renderer.tsx","./src/components/nostr/kinds/kind7renderer.tsx","./src/components/nostr/kinds/kind9735renderer.tsx","./src/components/nostr/kinds/kind9802detailrenderer.tsx","./src/components/nostr/kinds/kind9802renderer.tsx","./src/components/nostr/kinds/index.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/constants/kinds.ts","./src/constants/nips.ts","./src/core/logic.ts","./src/core/state.ts","./src/hooks/useaccountsync.ts","./src/hooks/usenip.ts","./src/hooks/usenip05.ts","./src/hooks/usenostrevent.ts","./src/hooks/useprofile.ts","./src/hooks/usereqtimeline.ts","./src/hooks/usetimeline.ts","./src/lib/decode-parser.ts","./src/lib/encode-parser.ts","./src/lib/imeta.ts","./src/lib/nip-kinds.ts","./src/lib/nip05.ts","./src/lib/nostr-utils.ts","./src/lib/open-parser.ts","./src/lib/profile-parser.ts","./src/lib/req-parser.ts","./src/lib/utils.ts","./src/services/accounts.ts","./src/services/db.ts","./src/services/event-store.ts","./src/services/loaders.ts","./src/services/relay-pool.ts","./src/types/app.ts","./src/types/man.ts","./src/types/nostr.ts","./src/types/profile.ts"],"errors":true,"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user