diff --git a/CLAUDE.md b/CLAUDE.md index 44c2e9e..801421a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,13 @@ Grimoire is a Nostr protocol explorer and developer tool. It's a tiling window m - Reactive: components subscribe via hooks, auto-update on new events - Handles replaceable events automatically (profiles, contact lists, etc.) -**Critical**: Don't create new EventStore or RelayPool instances - use the singletons in `src/services/` +**Relay State** (`src/services/relay-liveness.ts`): +- Singleton `RelayLiveness` tracks relay health across sessions +- Persisted to Dexie `relayLiveness` table +- Maintains failure counts, backoff states, last success/failure times +- Prevents repeated connection attempts to dead relays + +**Critical**: Don't create new EventStore, RelayPool, or RelayLiveness instances - use the singletons in `src/services/` ### Window System @@ -82,11 +88,23 @@ Use hooks like `useProfile()`, `useNostrEvent()`, `useTimeline()` - they handle - Active account stored in Jotai state, synced via `useAccountSync` hook - Use inbox/outbox relay pattern for user relay lists +**Event Rendering**: +- Feed renderers: `KindRenderer` component with `renderers` registry in `src/components/nostr/kinds/index.tsx` +- Detail renderers: `DetailKindRenderer` component with `detailRenderers` registry +- Registry pattern allows adding new kind renderers without modifying parent components +- Falls back to `DefaultKindRenderer` or feed renderer for unregistered kinds + **Mosaic Layout**: - Layout mutations via `updateLayout()` callback only - Don't traverse or modify layout tree manually - Adding/removing windows handled by `logic.ts` functions +**Error Boundaries**: +- All event renderers wrapped in `EventErrorBoundary` component +- Prevents one broken event from crashing entire feed or detail view +- Provides diagnostic UI with retry capability and error details +- Error boundaries auto-reset when event changes + ## Testing **Test Framework**: Vitest with node environment diff --git a/TODO.md b/TODO.md index 01c0472..9ba23c9 100644 --- a/TODO.md +++ b/TODO.md @@ -24,24 +24,11 @@ Current RTL implementation is partial and has limitations: **Test case**: Arabic text with hashtags on same line should display properly with right-alignment. -### Live Mode Reliability -**Priority**: High -**File**: `src/components/ReqViewer.tsx` - -**Issues**: -- Live mode sometimes stops updating (gets stuck) -- May be related to reconnection on errors -- Compact live indicator needed for better UX - -**Investigation needed**: Check relay reconnection logic and subscription lifecycle. - ### Rendering Issues **Priority**: Medium -- **Window crashes on unsupported kind event** - Need graceful error handling for unknown kinds - **Nested lists in Markdown should be padded** - Markdown renderer spacing issue - **Text rendering**: Avoid inserting `
` tags, investigate noStrudel's EOL metadata approach -- **JSON viewer scrolling**: Expandable JSON in event details cannot be scrolled when content exceeds available height - needs overflow handling ## Command Palette / UX Improvements @@ -88,9 +75,41 @@ When an action is entered, show the list of available options below and provide **Priority**: Low **Description**: Allow setting background color or theme for individual columns, helping visually organize workspace. +## Recent Improvements ✅ + +### Relay Liveness Persistence +**Completed**: 2024-12-17 +**Files**: `src/services/db.ts`, `src/services/relay-liveness.ts` + +Relay health tracking now persists across sessions: +- Added Dexie v8 migration with `relayLiveness` table +- Created storage adapter implementing `LivenessStorage` interface +- Relay failure counts, backoff states persist across app restarts +- Prevents repeated connection attempts to dead relays + +### Detail Renderer Registry +**Completed**: 2024-12-17 +**Files**: `src/components/nostr/kinds/index.tsx`, `src/components/EventDetailViewer.tsx` + +Refactored detail view rendering to use registry pattern: +- Removed 25-line hardcoded if-else chain from EventDetailViewer +- Created `detailRenderers` map with 11 specialized detail renderers +- New detail renderers can be added without modifying EventDetailViewer +- Falls back to feed renderer for kinds without custom detail views + +### Event Error Boundaries +**Completed**: 2024-12-17 +**Files**: `src/components/EventErrorBoundary.tsx`, `src/components/nostr/Feed.tsx`, `src/components/EventDetailViewer.tsx` + +All event renderers now protected with error boundaries: +- One broken event no longer crashes entire feed +- Diagnostic UI shows kind, ID, error message, component stack +- Retry button and collapsible details for debugging +- Auto-resets when event changes + ## Planned Improvements -- **App-wide error boundary** - Splash crash screen for unhandled errors +- **App-wide error boundary** - Splash crash screen for unhandled errors (separate from event-level boundaries) - **Collapsible relay list** - Show user relay links without inbox/outbox icons initially - **NIP badges everywhere** - Use consistent NIP badge components for linking to NIP documentation - **External spec event kind support** - Add references and documentation links for commented-out event kinds from external specs (Blossom, Marmot Protocol, NKBIP, nostrocket, Corny Chat, NUD, etc.) in `src/constants/kinds.ts`. Consider adding a separate registry or documentation for non-official-NIP event kinds. @@ -258,20 +277,7 @@ When an action is entered, show the list of available options below and provide ### Phase 1: Foundation Fixes (1-2 weeks) **Goal:** Fix critical architectural issues and quick wins -#### 1.1 Unified Detail Renderer Registry -**Priority**: High | **Effort**: Low -**Files**: `src/components/nostr/kinds/index.tsx`, `src/components/EventDetailViewer.tsx` - -**Problem**: EventDetailViewer uses hardcoded switch statement instead of registry pattern -```tsx -// Current anti-pattern: -event.kind === kinds.Metadata ? -: event.kind === kinds.Contacts ? -``` - -**Solution**: Create `detailRenderers` map parallel to `kindRenderers` with fallback logic - -#### 1.2 Systematic Depth Tracking +#### 1.1 Systematic Depth Tracking **Priority**: High | **Effort**: Medium **Files**: All `*Renderer.tsx` files @@ -283,15 +289,7 @@ event.kind === kinds.Metadata ? - Audit all renderers using `EmbeddedEvent` - Implement `CollapsedPreview` component for max depth exceeded -#### 1.3 Error Boundaries -**Priority**: High | **Effort**: Low -**File**: `src/components/EventErrorBoundary.tsx` - -**Problem**: One broken event crashes entire feed - -**Solution**: Create `EventErrorBoundary` component wrapping all events with diagnostic error cards - -#### 1.4 Renderer Memoization +#### 1.2 Renderer Memoization **Priority**: Medium | **Effort**: Low **Files**: All `*Renderer.tsx` files diff --git a/src/components/ConnViewer.tsx b/src/components/ConnViewer.tsx index e27cd29..111fcd2 100644 --- a/src/components/ConnViewer.tsx +++ b/src/components/ConnViewer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { toast } from "sonner"; import { Wifi, @@ -11,6 +11,10 @@ import { Shield, XCircle, Settings, + Activity, + Clock, + AlertCircle, + Skull, } from "lucide-react"; import { useRelayState } from "@/hooks/useRelayState"; import type { RelayState } from "@/types/relay-state"; @@ -26,6 +30,7 @@ import { DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { isAuthPreference } from "@/lib/type-guards"; +import liveness from "@/services/relay-liveness"; /** * CONN viewer - displays connection and auth status for all relays in the pool @@ -44,6 +49,9 @@ function ConnViewer() { .filter((r) => r.connectionState !== "connected") .sort((a, b) => a.url.localeCompare(b.url)); + // Get all seen relays for liveness section + const seenRelays = liveness.getSeenRelays().sort(); + return (
{/* Relay List */} @@ -77,6 +85,18 @@ function ConnViewer() { ))} )} + + {/* Relay Liveness Stats */} + {seenRelays.length > 0 && ( + <> +
+ Relay Liveness +
+ {seenRelays.map((url) => ( + + ))} + + )}
); @@ -253,4 +273,92 @@ function RelayCard({ relay }: RelayCardProps) { ); } +interface LivenessStatsRowProps { + url: string; +} + +function LivenessStatsRow({ url }: LivenessStatsRowProps) { + const [livenessState, setLivenessState] = useState(liveness.getState(url)); + + // Subscribe to liveness state updates + useEffect(() => { + const subscription = liveness.state(url).subscribe((state) => { + setLivenessState(state); + }); + + return () => subscription.unsubscribe(); + }, [url]); + + // Format liveness state icon and label + const livenessIcon = () => { + if (!livenessState) { + return { icon: , label: "Unknown" }; + } + + const iconMap = { + online: { icon: , label: "Online" }, + offline: { icon: , label: "Offline" }, + dead: { icon: , label: "Dead" }, + }; + return iconMap[livenessState.state]; + }; + + // Format backoff remaining time + const formatBackoffTime = (ms: number) => { + if (ms <= 0) return null; + const seconds = Math.ceil(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.ceil(seconds / 60); + return `${minutes}m`; + }; + + const backoffRemaining = livenessState ? liveness.getBackoffRemaining(url) : 0; + const isInBackoff = backoffRemaining > 0; + + if (!livenessState) { + return null; + } + + return ( +
+
+ +
+ + +
{livenessIcon().icon}
+
+ +

{livenessIcon().label}

+
+
+
+ + {livenessState.failureCount} +
+ {isInBackoff && ( + + +
+ + {formatBackoffTime(backoffRemaining)} +
+
+ +

Backoff

+
+
+ )} +
+
+
+ ); +} + export default ConnViewer; diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index 9171b64..0e93200 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -1,18 +1,8 @@ import { useState } from "react"; import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; import { useNostrEvent } from "@/hooks/useNostrEvent"; -import { KindRenderer } from "./nostr/kinds"; -import { Kind0DetailRenderer } from "./nostr/kinds/ProfileDetailRenderer"; -import { Kind3DetailView } from "./nostr/kinds/ContactListRenderer"; -import { IssueDetailRenderer } from "./nostr/kinds/IssueDetailRenderer"; -import { PatchDetailRenderer } from "./nostr/kinds/PatchDetailRenderer"; -import { PullRequestDetailRenderer } from "./nostr/kinds/PullRequestDetailRenderer"; -import { Kind1337DetailRenderer } from "./nostr/kinds/CodeSnippetDetailRenderer"; -import { Kind9802DetailRenderer } from "./nostr/kinds/HighlightDetailRenderer"; -import { Kind10002DetailRenderer } from "./nostr/kinds/RelayListDetailRenderer"; -import { Kind30023DetailRenderer } from "./nostr/kinds/ArticleDetailRenderer"; -import { CommunityNIPDetailRenderer } from "./nostr/kinds/CommunityNIPDetailRenderer"; -import { RepositoryDetailRenderer } from "./nostr/kinds/RepositoryDetailRenderer"; +import { DetailKindRenderer } from "./nostr/kinds"; +import { EventErrorBoundary } from "./EventErrorBoundary"; import { JsonViewer } from "./JsonViewer"; import { RelayLink } from "./nostr/RelayLink"; import { EventDetailSkeleton } from "@/components/ui/skeleton"; @@ -24,7 +14,7 @@ import { DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; -import { nip19, kinds } from "nostr-tools"; +import { nip19 } from "nostr-tools"; import { useCopy } from "../hooks/useCopy"; import { getSeenRelays } from "applesauce-core/helpers/relays"; import { getTagValue } from "applesauce-core/helpers"; @@ -182,31 +172,9 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { {/* Rendered Content - Focus Here */}
- {event.kind === kinds.Metadata ? ( - - ) : event.kind === kinds.Contacts ? ( - - ) : event.kind === 1337 ? ( - - ) : event.kind === 1617 ? ( - - ) : event.kind === 1618 ? ( - - ) : event.kind === 1621 ? ( - - ) : event.kind === kinds.Highlights ? ( - - ) : event.kind === kinds.RelayList ? ( - - ) : event.kind === kinds.LongFormArticle ? ( - - ) : event.kind === 30817 ? ( - - ) : event.kind === 30617 ? ( - - ) : ( - - )} + + +
{/* JSON Viewer Dialog */} diff --git a/src/components/EventErrorBoundary.tsx b/src/components/EventErrorBoundary.tsx new file mode 100644 index 0000000..b84ae4a --- /dev/null +++ b/src/components/EventErrorBoundary.tsx @@ -0,0 +1,169 @@ +import React, { Component, ReactNode } from "react"; +import { AlertTriangle, Bug, FileJson, RefreshCw } from "lucide-react"; +import type { NostrEvent } from "@/types/nostr"; +import { nip19 } from "nostr-tools"; +import { Button } from "./ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; + +interface EventErrorBoundaryProps { + children: ReactNode; + event: NostrEvent; +} + +interface EventErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: React.ErrorInfo | null; + showDetails: boolean; +} + +/** + * Error boundary for event renderers + * Catches rendering errors and displays diagnostic information + * Prevents one broken event from crashing the entire feed + */ +export class EventErrorBoundary extends Component< + EventErrorBoundaryProps, + EventErrorBoundaryState +> { + constructor(props: EventErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }; + } + + static getDerivedStateFromError(_error: Error): Partial { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + // Log error to console for debugging + console.error("[EventErrorBoundary] Caught rendering error:", error, errorInfo); + + this.setState({ + error, + errorInfo, + }); + } + + componentDidUpdate(prevProps: EventErrorBoundaryProps) { + // Reset error boundary if event changes + if (prevProps.event.id !== this.props.event.id) { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }); + } + } + + handleRetry = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }); + }; + + render() { + if (this.state.hasError) { + const { event } = this.props; + const { error, errorInfo, showDetails } = this.state; + + // Generate event ID for debugging + const eventId = nip19.noteEncode(event.id); + + return ( +
+
+ +
+

+ Rendering Error +

+

+ This event failed to render. The error has been logged to the console. +

+ +
+
+ + Kind {event.kind} +
+
+ + + {eventId.slice(0, 16)}... + +
+
+ +
+ + + this.setState({ showDetails: open })} + > + + + + +
+ {error && ( +
+
+ Error: +
+
+                            {error.toString()}
+                          
+
+ )} + {errorInfo && errorInfo.componentStack && ( +
+
+ Component Stack: +
+
+                            {errorInfo.componentStack}
+                          
+
+ )} +
+
+ Event JSON: +
+
+                          {JSON.stringify(event, null, 2)}
+                        
+
+
+
+
+
+
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/nostr/Feed.tsx b/src/components/nostr/Feed.tsx index 8b05fb6..1b1382f 100644 --- a/src/components/nostr/Feed.tsx +++ b/src/components/nostr/Feed.tsx @@ -2,6 +2,7 @@ import { useTimeline } from "@/hooks/useTimeline"; import { kinds } from "nostr-tools"; import { NostrEvent } from "@/types/nostr"; import { KindRenderer } from "./kinds"; +import { EventErrorBoundary } from "../EventErrorBoundary"; interface FeedEventProps { event: NostrEvent; @@ -9,9 +10,14 @@ interface FeedEventProps { /** * FeedEvent - Renders a single event using the appropriate kind renderer + * Wrapped in error boundary to prevent one broken event from crashing the feed */ export function FeedEvent({ event }: FeedEventProps) { - return ; + return ( + + + + ); } /** diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 26b3f1e..d8c65ed 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -1,7 +1,9 @@ import { Kind0Renderer } from "./ProfileRenderer"; +import { Kind0DetailRenderer } from "./ProfileDetailRenderer"; import { Kind1Renderer } from "./NoteRenderer"; import { Kind1111Renderer } from "./Kind1111Renderer"; import { Kind3Renderer } from "./ContactListRenderer"; +import { Kind3DetailView } from "./ContactListRenderer"; import { RepostRenderer } from "./RepostRenderer"; import { Kind7Renderer } from "./ReactionRenderer"; import { Kind9Renderer } from "./ChatMessageRenderer"; @@ -10,15 +12,24 @@ import { Kind21Renderer } from "./VideoRenderer"; import { Kind22Renderer } from "./ShortVideoRenderer"; import { Kind1063Renderer } from "./FileMetadataRenderer"; import { Kind1337Renderer } from "./CodeSnippetRenderer"; +import { Kind1337DetailRenderer } from "./CodeSnippetDetailRenderer"; import { IssueRenderer } from "./IssueRenderer"; +import { IssueDetailRenderer } from "./IssueDetailRenderer"; import { PatchRenderer } from "./PatchRenderer"; +import { PatchDetailRenderer } from "./PatchDetailRenderer"; import { PullRequestRenderer } from "./PullRequestRenderer"; +import { PullRequestDetailRenderer } from "./PullRequestDetailRenderer"; import { Kind9735Renderer } from "./ZapReceiptRenderer"; import { Kind9802Renderer } from "./HighlightRenderer"; +import { Kind9802DetailRenderer } from "./HighlightDetailRenderer"; import { Kind10002Renderer } from "./RelayListRenderer"; +import { Kind10002DetailRenderer } from "./RelayListDetailRenderer"; import { Kind30023Renderer } from "./ArticleRenderer"; +import { Kind30023DetailRenderer } from "./ArticleDetailRenderer"; import { CommunityNIPRenderer } from "./CommunityNIPRenderer"; +import { CommunityNIPDetailRenderer } from "./CommunityNIPDetailRenderer"; import { RepositoryRenderer } from "./RepositoryRenderer"; +import { RepositoryDetailRenderer } from "./RepositoryDetailRenderer"; import { Kind39701Renderer } from "./BookmarkRenderer"; import { GenericRelayListRenderer } from "./GenericRelayListRenderer"; import { NostrEvent } from "@/types/nostr"; @@ -92,10 +103,46 @@ export function KindRenderer({ return ; } +/** + * Registry of kind-specific detail renderers (for detail views) + * Maps event kinds to their detailed renderer components + */ +const detailRenderers: Record> = { + 0: Kind0DetailRenderer, // Profile Metadata Detail + 3: Kind3DetailView, // Contact List Detail + 1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0) + 1617: PatchDetailRenderer, // Patch Detail (NIP-34) + 1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34) + 1621: IssueDetailRenderer, // Issue Detail (NIP-34) + 9802: Kind9802DetailRenderer, // Highlight Detail + 10002: Kind10002DetailRenderer, // Relay List Detail (NIP-65) + 30023: Kind30023DetailRenderer, // Long-form Article Detail + 30617: RepositoryDetailRenderer, // Repository Detail (NIP-34) + 30817: CommunityNIPDetailRenderer, // Community NIP Detail +}; + +/** + * Default detail renderer for kinds without custom detail implementations + * Falls back to the feed renderer + */ +function DefaultDetailRenderer({ event }: { event: NostrEvent }) { + return ; +} + +/** + * Main DetailKindRenderer component + * Automatically selects the appropriate detail renderer based on event kind + * Falls back to feed renderer if no detail renderer exists + */ +export function DetailKindRenderer({ event }: { event: NostrEvent }) { + const Renderer = detailRenderers[event.kind] || DefaultDetailRenderer; + return ; +} + /** * Export kind renderers registry for dynamic kind detection */ -export { kindRenderers }; +export { kindRenderers, detailRenderers }; /** * Export individual renderers and base components for reuse diff --git a/src/services/db.ts b/src/services/db.ts index c14a35e..d6df31b 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -40,6 +40,15 @@ export interface CachedRelayList { updatedAt: number; } +export interface RelayLivenessEntry { + url: string; + state: "online" | "offline" | "dead"; + failureCount: number; + lastFailureTime: number; + lastSuccessTime: number; + backoffUntil?: number; +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -47,6 +56,7 @@ class GrimoireDb extends Dexie { relayInfo!: Table; relayAuthPreferences!: Table; relayLists!: Table; + relayLiveness!: Table; constructor(name: string) { super(name); @@ -159,9 +169,42 @@ class GrimoireDb extends Dexie { relayAuthPreferences: "&url", relayLists: "&pubkey, updatedAt", }); + + // Version 8: Add relay liveness tracking + this.version(8).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + }); } } const db = new GrimoireDb("grimoire-dev"); +/** + * Dexie storage adapter for RelayLiveness persistence + * Implements the LivenessStorage interface expected by applesauce-relay + */ +export const relayLivenessStorage = { + async getItem(key: string): Promise { + const entry = await db.relayLiveness.get(key); + if (!entry) return null; + + // Return RelayState object without the url field + const { url, ...state } = entry; + return state; + }, + + async setItem(key: string, value: any): Promise { + await db.relayLiveness.put({ + url: key, + ...value, + }); + }, +}; + export default db; diff --git a/src/services/relay-liveness.ts b/src/services/relay-liveness.ts index 5e81c4e..e22163d 100644 --- a/src/services/relay-liveness.ts +++ b/src/services/relay-liveness.ts @@ -7,8 +7,9 @@ import { RelayLiveness } from "applesauce-relay"; import pool from "./relay-pool"; +import { relayLivenessStorage } from "./db"; -// Create singleton liveness tracker +// Create singleton liveness tracker with persistent storage const liveness = new RelayLiveness({ // Maximum failures before marking relay as dead maxFailuresBeforeDead: 5, @@ -16,8 +17,13 @@ const liveness = new RelayLiveness({ backoffBaseDelay: 30 * 1000, // Maximum backoff delay (5 minutes) backoffMaxDelay: 5 * 60 * 1000, - // TODO: Add persistent storage using Dexie - // storage: undefined, + // Persistent storage using Dexie + storage: relayLivenessStorage, +}); + +// Load persisted relay states on initialization +liveness.load().catch((error) => { + console.warn("[RelayLiveness] Failed to load persisted state:", error); }); // Connect to relay pool to automatically track relay health