mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 17:51:12 +02:00
feat: relay liveness tracking
This commit is contained in:
20
CLAUDE.md
20
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
|
||||
|
||||
72
TODO.md
72
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 `<br>` 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 ? <Kind0DetailRenderer />
|
||||
: event.kind === kinds.Contacts ? <Kind3DetailView />
|
||||
```
|
||||
|
||||
**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 ? <Kind0DetailRenderer />
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-full w-full flex flex-col bg-background text-foreground">
|
||||
{/* Relay List */}
|
||||
@@ -77,6 +85,18 @@ function ConnViewer() {
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Relay Liveness Stats */}
|
||||
{seenRelays.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-semibold text-muted-foreground">
|
||||
Relay Liveness
|
||||
</div>
|
||||
{seenRelays.map((url) => (
|
||||
<LivenessStatsRow key={url} url={url} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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: <Activity className="size-4 text-muted-foreground" />, label: "Unknown" };
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
online: { icon: <Activity className="size-4 text-green-500" />, label: "Online" },
|
||||
offline: { icon: <WifiOff className="size-4 text-yellow-500" />, label: "Offline" },
|
||||
dead: { icon: <Skull className="size-4 text-red-500" />, 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 (
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<RelayLink
|
||||
url={url}
|
||||
showInboxOutbox={false}
|
||||
className="line-clamp-1 hover:bg-transparent hover:underline hover:decoration-dotted"
|
||||
iconClassname="size-4"
|
||||
urlClassname="text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground font-mono flex-shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help">{livenessIcon().icon}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{livenessIcon().label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertCircle className="size-4" />
|
||||
<span>{livenessState.failureCount}</span>
|
||||
</div>
|
||||
{isInBackoff && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help flex items-center gap-1.5 text-yellow-500">
|
||||
<Clock className="size-4" />
|
||||
<span>{formatBackoffTime(backoffRemaining)}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Backoff</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnViewer;
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{event.kind === kinds.Metadata ? (
|
||||
<Kind0DetailRenderer event={event} />
|
||||
) : event.kind === kinds.Contacts ? (
|
||||
<Kind3DetailView event={event} />
|
||||
) : event.kind === 1337 ? (
|
||||
<Kind1337DetailRenderer event={event} />
|
||||
) : event.kind === 1617 ? (
|
||||
<PatchDetailRenderer event={event} />
|
||||
) : event.kind === 1618 ? (
|
||||
<PullRequestDetailRenderer event={event} />
|
||||
) : event.kind === 1621 ? (
|
||||
<IssueDetailRenderer event={event} />
|
||||
) : event.kind === kinds.Highlights ? (
|
||||
<Kind9802DetailRenderer event={event} />
|
||||
) : event.kind === kinds.RelayList ? (
|
||||
<Kind10002DetailRenderer event={event} />
|
||||
) : event.kind === kinds.LongFormArticle ? (
|
||||
<Kind30023DetailRenderer event={event} />
|
||||
) : event.kind === 30817 ? (
|
||||
<CommunityNIPDetailRenderer event={event} />
|
||||
) : event.kind === 30617 ? (
|
||||
<RepositoryDetailRenderer event={event} />
|
||||
) : (
|
||||
<KindRenderer event={event} />
|
||||
)}
|
||||
<EventErrorBoundary event={event}>
|
||||
<DetailKindRenderer event={event} />
|
||||
</EventErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* JSON Viewer Dialog */}
|
||||
|
||||
169
src/components/EventErrorBoundary.tsx
Normal file
169
src/components/EventErrorBoundary.tsx
Normal file
@@ -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<EventErrorBoundaryState> {
|
||||
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 (
|
||||
<div className="border border-destructive bg-destructive/10 p-4 my-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-destructive mb-1">
|
||||
Rendering Error
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
This event failed to render. The error has been logged to the console.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3 text-xs">
|
||||
<div className="flex items-center gap-1.5 bg-background/50 px-2 py-1 border border-border">
|
||||
<Bug className="size-3" />
|
||||
<span className="font-mono">Kind {event.kind}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-background/50 px-2 py-1 border border-border">
|
||||
<FileJson className="size-3" />
|
||||
<span className="font-mono truncate max-w-[200px]" title={eventId}>
|
||||
{eventId.slice(0, 16)}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={this.handleRetry}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<RefreshCw className="size-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
|
||||
<Collapsible
|
||||
open={showDetails}
|
||||
onOpenChange={(open) => this.setState({ showDetails: open })}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
{showDetails ? "Hide" : "Show"} Details
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3">
|
||||
<div className="bg-background border border-border p-3 text-xs space-y-2">
|
||||
{error && (
|
||||
<div>
|
||||
<div className="font-semibold text-destructive mb-1">
|
||||
Error:
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{errorInfo && errorInfo.componentStack && (
|
||||
<div>
|
||||
<div className="font-semibold text-destructive mb-1">
|
||||
Component Stack:
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto">
|
||||
{errorInfo.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-semibold text-destructive mb-1">
|
||||
Event JSON:
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto">
|
||||
{JSON.stringify(event, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -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 <KindRenderer event={event} />;
|
||||
return (
|
||||
<EventErrorBoundary event={event}>
|
||||
<KindRenderer event={event} />
|
||||
</EventErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <Renderer event={event} depth={depth} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of kind-specific detail renderers (for detail views)
|
||||
* Maps event kinds to their detailed renderer components
|
||||
*/
|
||||
const detailRenderers: Record<number, React.ComponentType<{ event: NostrEvent }>> = {
|
||||
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 <KindRenderer event={event} depth={0} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <Renderer event={event} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export kind renderers registry for dynamic kind detection
|
||||
*/
|
||||
export { kindRenderers };
|
||||
export { kindRenderers, detailRenderers };
|
||||
|
||||
/**
|
||||
* Export individual renderers and base components for reuse
|
||||
|
||||
@@ -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<Profile>;
|
||||
nip05!: Table<Nip05>;
|
||||
@@ -47,6 +56,7 @@ class GrimoireDb extends Dexie {
|
||||
relayInfo!: Table<RelayInfo>;
|
||||
relayAuthPreferences!: Table<RelayAuthPreference>;
|
||||
relayLists!: Table<CachedRelayList>;
|
||||
relayLiveness!: Table<RelayLivenessEntry>;
|
||||
|
||||
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<any> {
|
||||
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<void> {
|
||||
await db.relayLiveness.put({
|
||||
url: key,
|
||||
...value,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user