diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index dc91dc6..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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 `` 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 diff --git a/index.html b/index.html index 0761dc8..bf1d45f 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,13 @@ - Grimoire - Nostr Client + + + + grimoire - a nostr client for magicians
diff --git a/src/components/KindRenderer.tsx b/src/components/KindRenderer.tsx index 588190a..021ef13 100644 --- a/src/components/KindRenderer.tsx +++ b/src/components/KindRenderer.tsx @@ -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 (
{/* Header */} -
+
{Icon && ( -
- +
+
)}
-
- -

{kindInfo.name}

{kindInfo.description}

@@ -86,7 +82,7 @@ export default function KindRenderer({ kind }: { kind: number }) { ( -

{children}

- ), - h2: ({ children }) => ( -

{children}

- ), - h3: ({ children }) => ( -

{children}

- ), - h4: ({ children }) => ( -

{children}

- ), - h5: ({ children }) => ( -
{children}
- ), - h6: ({ children }) => ( -
{children}
- ), - - // Paragraphs and text - p: ({ children }) => ( -

- {children} -

- ), - - // 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 ( - { - e.preventDefault(); - addWindow("nip", { number: nipNumber }, `NIP ${nipNumber}`); - }} - className="text-primary underline decoration-dotted cursor-pointer hover:text-primary/80 transition-colors" - > - {children} - - ); - } - } - - // Regular external link - return ( -
({ + // Headings + h1: ({ children }) => ( +

{children} - - ); - }, - - // Lists - ul: ({ children }) => ( -
    - {children} -
- ), - ol: ({ children }) => ( -
    - {children} -
- ), - li: ({ children }) =>
  • {children}
  • , - - // Blockquotes - blockquote: ({ children }) => ( -
    - {children} -
    - ), - - // Code - code: (props) => { - const { children, className } = props; - const inline = !className?.includes("language-"); - return inline ? ( - +

    + ), + h2: ({ children }) => ( +

    {children} - - ) : ( - +

    + ), + h3: ({ children }) => ( +

    {children} - - ); - }, - pre: ({ children }) =>
    {children}
    , - - // Horizontal rule - hr: () =>
    , - - // Tables - table: ({ children }) => ( -
    - + + ), + h4: ({ children }) => ( +

    {children} -

    -
    - ), - thead: ({ children }) => {children}, - tbody: ({ children }) => {children}, - tr: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - {children} - ), +

    + ), + h5: ({ children }) => ( +
    + {children} +
    + ), + h6: ({ children }) => ( +
    + {children} +
    + ), - // Images - Inline with zoom - img: ({ src, alt }) => - src ? ( - - ) : null, + // Paragraphs and text + p: ({ children }) => ( +

    + {children} +

    + ), - // Emphasis - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - }; + // 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 ( + { + 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} + + ); + } + } + + // Regular external link + console.log("[Markdown] Creating regular external link for:", href); + return ( + + {children} + + ); + }, + + // Lists + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) => ( +
  • + {children} +
  • + ), + + // Blockquotes + blockquote: ({ children }) => ( +
    + {children} +
    + ), + + // Code + code: (props) => { + const { children, className } = props; + const inline = !className?.includes("language-"); + + return inline ? ( + + {children} + + ) : ( + + {children} + + ); + }, + pre: ({ children }) =>
    {children}
    , + + // Horizontal rule + hr: () =>
    , + + // Tables + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + {children} + ), + + // Images - Inline with zoom + img: ({ src, alt }) => + src ? ( + + ) : null, + + // Emphasis + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => {children}, + }), + [addWindow], + ); return (
    diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx index ab1c043..2541be5 100644 --- a/src/components/nostr/RichText.tsx +++ b/src/components/nostr/RichText.tsx @@ -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 ( - - {content.trim()} - +
    + {lines.map((line, idx) => ( +
    + {line || "\u00A0"} +
    + ))} +
    ); } @@ -52,14 +54,9 @@ export function RichText({ event, content, className = "" }: RichTextProps) { }; const renderedContent = useRenderedContent(trimmedEvent, contentComponents); return ( - +
    {renderedContent} - +
    ); } diff --git a/src/components/nostr/RichText/Text.tsx b/src/components/nostr/RichText/Text.tsx index 299b5b8..fda1be3 100644 --- a/src/components/nostr/RichText/Text.tsx +++ b/src/components/nostr/RichText/Text.tsx @@ -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 {text || "\u00A0"}; + } + + // 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 ( +
    + {line || "\u00A0"} +
    + ); + })} + + ); } diff --git a/src/components/nostr/UserName.tsx b/src/components/nostr/UserName.tsx index 9c5e3f2..4bc65e9 100644 --- a/src/components/nostr/UserName.tsx +++ b/src/components/nostr/UserName.tsx @@ -25,6 +25,7 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) { return ( diff --git a/src/components/nostr/kinds/Kind1063Renderer.tsx b/src/components/nostr/kinds/Kind1063Renderer.tsx new file mode 100644 index 0000000..e553c24 --- /dev/null +++ b/src/components/nostr/kinds/Kind1063Renderer.tsx @@ -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 ( + +
    + {/* File preview */} + {metadata.url && (isImage || isVideo || isAudio) ? ( +
    + {isImage && ( + + )} + {isVideo && ( + + )} + {isAudio && ( + + )} +
    + ) : ( + /* Non-media file preview */ +
    + +
    +

    {fileName}

    + {metadata.m && ( +

    {metadata.m}

    + )} +
    + {metadata.url && ( + + + Download + + )} +
    + )} + + {/* File metadata */} +
    + {metadata.m && ( + <> + Type + {metadata.m} + + )} + {metadata.size && ( + <> + Size + {formatFileSize(metadata.size)} + + )} + {metadata.dim && ( + <> + Dimensions + {metadata.dim} + + )} + {metadata.x && ( + <> + Hash + {metadata.x} + + )} +
    + + {/* Description/Summary */} + {summary && } +
    +
    + ); +} diff --git a/src/components/nostr/kinds/Kind20Renderer.tsx b/src/components/nostr/kinds/Kind20Renderer.tsx new file mode 100644 index 0000000..5405ca5 --- /dev/null +++ b/src/components/nostr/kinds/Kind20Renderer.tsx @@ -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 ( + +
    + {/* Title if present */} + {title && ( +

    + {title} +

    + )} + + {/* Images */} + {images.length > 0 && ( +
    + {images.map((img, i) => ( + + ))} +
    + )} + + {/* Description */} + {event.content && } +
    +
    + ); +} diff --git a/src/components/nostr/kinds/Kind21Renderer.tsx b/src/components/nostr/kinds/Kind21Renderer.tsx new file mode 100644 index 0000000..6ec00f5 --- /dev/null +++ b/src/components/nostr/kinds/Kind21Renderer.tsx @@ -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 ( + +
    + {/* Title if present */} + {title &&

    {title}

    } + + {/* Video - use first video from imeta or preview image if video fails */} + {videos.length > 0 && ( + + )} + + {/* Description */} + {event.content && } +
    +
    + ); +} diff --git a/src/components/nostr/kinds/Kind22Renderer.tsx b/src/components/nostr/kinds/Kind22Renderer.tsx new file mode 100644 index 0000000..a066fa3 --- /dev/null +++ b/src/components/nostr/kinds/Kind22Renderer.tsx @@ -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 ( + +
    + {/* Title if present */} + {title &&

    {title}

    } + + {/* Short video - optimized for portrait */} + {videos.length > 0 && ( + + )} + + {/* Description */} + {event.content && } +
    +
    + ); +} diff --git a/src/components/nostr/kinds/Kind7Renderer.tsx b/src/components/nostr/kinds/Kind7Renderer.tsx index f802081..8a7e354 100644 --- a/src/components/nostr/kinds/Kind7Renderer.tsx +++ b/src/components/nostr/kinds/Kind7Renderer.tsx @@ -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, diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 8ff8e72..f5078b9 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -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> = { 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"; diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 8a66c85..2fdf39f 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -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( diff --git a/src/core/logic.ts b/src/core/logic.ts index 4c3c526..e7c2e9e 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -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: { diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts index e2edc9e..cc2843e 100644 --- a/src/hooks/useAccountSync.ts +++ b/src/hooks/useAccountSync.ts @@ -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]); } diff --git a/src/lib/imeta.ts b/src/lib/imeta.ts new file mode 100644 index 0000000..62630bc --- /dev/null +++ b/src/lib/imeta.ts @@ -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 = {}; + + // 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 = {}; + + 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]}`; +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index d4ecbc5..f2459b4 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file