feat: RTL support

This commit is contained in:
Alejandro Gómez
2025-12-10 13:00:39 +01:00
parent cd41034b2f
commit 5b00e42ddf
18 changed files with 688 additions and 352 deletions

165
CLAUDE.md
View File

@@ -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

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}>

View File

@@ -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>
);
}

View File

@@ -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>
);
})}
</>
);
}

View File

@@ -25,6 +25,7 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) {
return (
<span
dir="auto"
className={cn("cursor-pointer hover:underline", className)}
onClick={handleClick}
>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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(

View File

@@ -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: {

View File

@@ -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
View 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]}`;
}

View File

@@ -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"}