From 799538e5f6b73c1d33963121e5e49c65fe002112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Wed, 1 Apr 2026 09:13:37 +0200 Subject: [PATCH] chore: remove old docs --- ACCESSIBILITY_PLAN.md | 456 ------------ AGENTS.md | 178 ----- APPLESAUCE_REFACTORING_PLAN.md | 379 ---------- CODEBASE_ANALYSIS.md | 650 ---------------- GEMINI.md | 93 --- PLAN-APPLESAUCE-V5.md | 427 ----------- SPELL_SYSTEM_PLAN.md | 501 ------------- docs/req-viewer-improvement-plan.md | 1070 --------------------------- docs/req-viewer-state-analysis.md | 787 -------------------- 9 files changed, 4541 deletions(-) delete mode 100644 ACCESSIBILITY_PLAN.md delete mode 100644 AGENTS.md delete mode 100644 APPLESAUCE_REFACTORING_PLAN.md delete mode 100644 CODEBASE_ANALYSIS.md delete mode 100644 GEMINI.md delete mode 100644 PLAN-APPLESAUCE-V5.md delete mode 100644 SPELL_SYSTEM_PLAN.md delete mode 100644 docs/req-viewer-improvement-plan.md delete mode 100644 docs/req-viewer-state-analysis.md diff --git a/ACCESSIBILITY_PLAN.md b/ACCESSIBILITY_PLAN.md deleted file mode 100644 index 2de4979..0000000 --- a/ACCESSIBILITY_PLAN.md +++ /dev/null @@ -1,456 +0,0 @@ -# Accessibility Improvement Plan - -This document outlines the accessibility improvements planned for Grimoire to achieve WCAG 2.1 AA compliance. - -## Current State Assessment - -**Current Coverage**: ~16% of components have ARIA attributes - -| Category | Status | Details | -|----------|--------|---------| -| Keyboard Navigation | ⚠️ Partial | Cmd+K works, limited elsewhere | -| Screen Reader Support | ⚠️ Partial | Basic labels, missing live regions | -| Focus Management | ✅ Good | Visible focus rings | -| Color Contrast | ⚠️ Unchecked | No WCAG verification | -| Loading States | ✅ Good | Skeletons with aria-busy | -| Error Handling | ⚠️ Partial | Errors not announced | - ---- - -## Phase 1: Foundation (Priority: High) - -### 1.1 Keyboard Navigation Improvements - -**Files to update**: `CommandLauncher.tsx`, `Home.tsx`, `TabBar.tsx` - -```typescript -// Add keyboard shortcuts help modal (Cmd+?) -const KEYBOARD_SHORTCUTS = [ - { keys: ['⌘', 'K'], description: 'Open command palette' }, - { keys: ['⌘', '1-9'], description: 'Switch workspace' }, - { keys: ['Escape'], description: 'Close dialog/modal' }, - { keys: ['↑', '↓'], description: 'Navigate list items' }, - { keys: ['Enter'], description: 'Select/confirm' }, -]; -``` - -**Tasks**: -- [ ] Create `KeyboardShortcutsDialog` component -- [ ] Add `Cmd+?` shortcut to show help -- [ ] Add keyboard navigation to window tiles (focus, close, resize) -- [ ] Implement roving tabindex for command list -- [ ] Add skip links for main content areas - -### 1.2 Focus Management - -**Files to update**: `components/ui/dialog.tsx`, `GlobalAuthPrompt.tsx` - -```typescript -// Focus trap for modals -import { FocusTrap } from '@radix-ui/react-focus-trap'; - -// Return focus after dialog close -const previousFocusRef = useRef(null); - -useEffect(() => { - if (open) { - previousFocusRef.current = document.activeElement as HTMLElement; - } else { - previousFocusRef.current?.focus(); - } -}, [open]); -``` - -**Tasks**: -- [ ] Verify all dialogs trap focus properly -- [ ] Return focus to trigger element on close -- [ ] Add `autoFocus` to first interactive element in dialogs -- [ ] Prevent focus from leaving modal while open - -### 1.3 Screen Reader Announcements (Live Regions) - -**Create new file**: `src/components/ui/Announcer.tsx` - -```typescript -import { createContext, useContext, useState, useCallback } from 'react'; - -interface AnnouncerContextValue { - announce: (message: string, politeness?: 'polite' | 'assertive') => void; -} - -const AnnouncerContext = createContext(null); - -export function AnnouncerProvider({ children }: { children: React.ReactNode }) { - const [politeMessage, setPoliteMessage] = useState(''); - const [assertiveMessage, setAssertiveMessage] = useState(''); - - const announce = useCallback((message: string, politeness: 'polite' | 'assertive' = 'polite') => { - if (politeness === 'assertive') { - setAssertiveMessage(message); - setTimeout(() => setAssertiveMessage(''), 1000); - } else { - setPoliteMessage(message); - setTimeout(() => setPoliteMessage(''), 1000); - } - }, []); - - return ( - - {children} -
- {politeMessage} -
-
- {assertiveMessage} -
-
- ); -} - -export function useAnnounce() { - const context = useContext(AnnouncerContext); - if (!context) throw new Error('useAnnounce must be used within AnnouncerProvider'); - return context.announce; -} -``` - -**Integration points**: -- [ ] Wrap app in `AnnouncerProvider` -- [ ] Announce when command executes: "Opening profile viewer" -- [ ] Announce when window closes: "Window closed" -- [ ] Announce loading complete: "Timeline loaded, 50 events" -- [ ] Announce errors: "Error: Failed to load profile" - ---- - -## Phase 2: Form Accessibility (Priority: High) - -### 2.1 Form Error Association - -**Pattern to implement across all forms**: - -```typescript -interface FormFieldProps { - id: string; - label: string; - error?: string; - description?: string; -} - -function FormField({ id, label, error, description, children }: FormFieldProps) { - const errorId = `${id}-error`; - const descriptionId = `${id}-description`; - - return ( -
- - {description && ( - - {description} - - )} - {React.cloneElement(children as React.ReactElement, { - id, - 'aria-describedby': [ - description ? descriptionId : null, - error ? errorId : null, - ].filter(Boolean).join(' ') || undefined, - 'aria-invalid': !!error, - })} - {error && ( - - {error} - - )} -
- ); -} -``` - -**Files to update**: -- [ ] `SpellDialog.tsx` - Spell creation form -- [ ] `SettingsDialog.tsx` - Settings inputs -- [ ] `WorkspaceSettings.tsx` - Workspace name input -- [ ] `CommandLauncher.tsx` - Command input - -### 2.2 Required Field Indicators - -```typescript -// Add to Label component -function Label({ required, children, ...props }) { - return ( - - ); -} -``` - ---- - -## Phase 3: Component ARIA Improvements (Priority: Medium) - -### 3.1 Event Renderers - -**Base pattern for all renderers**: - -```typescript -// BaseEventRenderer.tsx additions -
-
- - -
-
- {children} -
-
-``` - -**Tasks**: -- [ ] Add `article` landmark to event containers -- [ ] Add proper `time` elements with dateTime -- [ ] Add aria-labels to interactive elements -- [ ] Ensure all buttons have labels (close, menu, copy, etc.) - -### 3.2 Feed/Timeline Components - -**Files to update**: `Feed.tsx`, `ReqViewer.tsx` - -```typescript -// Add feed landmarks -
-

Timeline

- {events.map((event, index) => ( -
- -
- ))} -
-``` - -**Tasks**: -- [ ] Add `role="feed"` to timeline containers -- [ ] Add `aria-posinset` and `aria-setsize` for virtual lists -- [ ] Add `aria-busy` during loading -- [ ] Announce when new events arrive - -### 3.3 Collapsible/Accordion - -**Files to update**: `ui/accordion.tsx`, `ui/collapsible.tsx` - -```typescript -// Ensure proper ARIA states - -
- {children} -
-``` - ---- - -## Phase 4: Color & Visual Accessibility (Priority: Medium) - -### 4.1 Color Contrast Audit - -**Tool**: Use `axe-core` for automated checking - -```bash -npm install -D @axe-core/react -``` - -```typescript -// Development-only accessibility audit -if (process.env.NODE_ENV === 'development') { - import('@axe-core/react').then(axe => { - axe.default(React, ReactDOM, 1000); - }); -} -``` - -**Known issues to check**: -- [ ] Muted foreground text (`hsl(215 20.2% 70%)`) -- [ ] Gradient text (`.text-grimoire-gradient`) -- [ ] Disabled state opacity (50%) -- [ ] Placeholder text color - -### 4.2 High Contrast Mode - -**Create theme option**: - -```css -/* Add to index.css */ -@media (prefers-contrast: more) { - :root { - --foreground: 0 0% 0%; - --background: 0 0% 100%; - --muted-foreground: 0 0% 30%; - --border: 0 0% 0%; - } - .dark { - --foreground: 0 0% 100%; - --background: 0 0% 0%; - --muted-foreground: 0 0% 80%; - --border: 0 0% 100%; - } -} -``` - -**Tasks**: -- [ ] Add system preference detection -- [ ] Create high-contrast theme variables -- [ ] Test with Windows High Contrast Mode -- [ ] Add manual toggle in settings - -### 4.3 Reduced Motion - -```css -/* Already partially implemented, verify coverage */ -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} -``` - -**Tasks**: -- [ ] Audit all animations (Framer Motion, CSS transitions) -- [ ] Ensure skeleton pulse respects preference -- [ ] Verify window transitions can be disabled - ---- - -## Phase 5: Testing & Documentation (Priority: High) - -### 5.1 Automated Testing - -**Add to CI pipeline**: - -```yaml -# .github/workflows/accessibility.yml -name: Accessibility Checks -on: [push, pull_request] -jobs: - a11y: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - - run: npm ci - - run: npm run build - - name: Run axe-core - run: npx @axe-core/cli http://localhost:4173 -``` - -### 5.2 Manual Testing Checklist - -**Keyboard-only testing**: -- [ ] Can navigate entire app without mouse -- [ ] Focus order is logical -- [ ] All interactive elements are reachable -- [ ] Can dismiss dialogs with Escape -- [ ] Can activate buttons with Enter/Space - -**Screen reader testing** (with VoiceOver/NVDA): -- [ ] Page structure is announced correctly -- [ ] Links and buttons describe their purpose -- [ ] Form fields have associated labels -- [ ] Errors are announced when they occur -- [ ] Loading states are announced - -**Visual testing**: -- [ ] Content readable at 200% zoom -- [ ] No horizontal scrolling at 320px width (for non-tiling views) -- [ ] Focus indicators visible -- [ ] Color not sole means of conveying info - -### 5.3 Accessibility Documentation - -**Create `docs/ACCESSIBILITY.md`**: -- Document keyboard shortcuts -- List known limitations -- Provide screen reader recommendations -- Document testing procedures - ---- - -## Implementation Phases - -### Phase 1: Foundation (2-3 weeks) -- Live region announcer -- Keyboard shortcuts help -- Focus management fixes - -### Phase 2: Forms (1-2 weeks) -- Error association pattern -- Required field indicators -- Form validation feedback - -### Phase 3: Components (2-3 weeks) -- Event renderer improvements -- Feed landmarks -- Dialog ARIA fixes - -### Phase 4: Visual (1-2 weeks) -- Color contrast audit -- High contrast mode -- Reduced motion support - -### Phase 5: Testing (Ongoing) -- Automated CI checks -- Manual testing protocol -- Documentation - ---- - -## Success Metrics - -| Metric | Current | Target | -|--------|---------|--------| -| axe-core violations | Unknown | 0 critical, <5 minor | -| ARIA coverage | 16% | 90%+ | -| Keyboard accessibility | Partial | Full | -| Color contrast ratio | Unknown | 4.5:1 minimum | -| WCAG 2.1 Level | Unknown | AA | - ---- - -## Resources - -- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/) -- [Radix UI Accessibility](https://www.radix-ui.com/docs/primitives/overview/accessibility) -- [React ARIA](https://react-spectrum.adobe.com/react-aria/) -- [axe-core Rules](https://dequeuniversity.com/rules/axe/) -- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index b3a38cf..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,178 +0,0 @@ -# AGENTS.md - -This file contains guidelines for agentic coding agents working in the Grimoire repository. - -## Quick Reference - -For detailed project architecture, patterns, and conventions, see **[CLAUDE.md](./CLAUDE.md)**. - -## Build Commands - -### Development -```bash -npm run dev # Start development server with HMR -npm run build # TypeScript check + production build -npm run preview # Preview production build locally -``` - -### Code Quality -```bash -npm run lint # Run ESLint (fails on errors) -npm run lint:fix # Auto-fix ESLint issues + formatting -npm run format # Format with Prettier -npm run format:check # Check formatting without changes -``` - -### Testing -```bash -npm test # Run tests in watch mode -npm run test:ui # Visual test explorer -npm run test:run # Single test run (CI mode) -``` - -**Single Test**: Use `vitest run` with file pattern: -```bash -npm run test:run src/lib/nostr-utils.test.ts -``` - -### Verification -Always run before committing: -```bash -npm run lint && npm run test:run && npm run build -``` - -## Code Style Guidelines - -### Imports & Dependencies -- **Path Alias**: Use `@/` for `src/` directory (configured in Vite & Vitest) -- **Import Order**: External libs → Applesauce → Local `@/` modules → Relative imports -- **Applesauce**: Prefer helpers from `applesauce-core/helpers` and `applesauce-common/helpers` -- **No Default Exports**: Use named exports for better tree-shaking - -```typescript -// ✅ Correct import order -import { useState } from "react"; -import { use$ } from "applesauce-react/hooks"; -import { getProfileContent } from "applesauce-core/helpers"; -import { cn } from "@/lib/utils"; -import { UserName } from "./components/UserName"; -``` - -### TypeScript & Types -- **Strict Mode**: Project uses strict TypeScript configuration -- **No `any`**: ESLint rule disabled, but prefer proper types -- **Applesauce Types**: Use types from `applesauce-core` and `nostr-tools` -- **Local Types**: Extend in `src/types/` when needed - -```typescript -// ✅ Use proper types from Applesauce -import type { NostrEvent, ProfileContent } from "applesauce-core/helpers"; -import type { EventPointer } from "nostr-tools/nip19"; -``` - -### React Patterns -- **React 19**: Use latest features (hooks, concurrent rendering) -- **No Default Exports**: Components use named exports -- **Props Interface**: Always define props interface with JSDoc comments -- **Destructured Props**: Destructure props in function signature - -```typescript -// ✅ Component pattern -interface QuotedEventProps { - /** EventPointer with optional relay hints */ - eventPointer?: EventPointer; - /** Depth level for nesting */ - depth?: number; -} - -export function QuotedEvent({ eventPointer, depth = 0 }: QuotedEventProps) { - // Component logic -} -``` - -### Performance & Caching -- **Applesauce Helpers**: Cache internally - NO `useMemo` needed -- **Custom Helpers**: Use `useMemo` for complex transformations -- **Stable References**: Use `useStableValue`/`useStableArray` for filters/options - -```typescript -// ❌ WRONG - Unnecessary memoization -const title = useMemo(() => getArticleTitle(event), [event]); - -// ✅ CORRECT - Helpers cache internally -const title = getArticleTitle(event); -``` - -### Styling -- **TailwindCSS**: Primary styling approach -- **CSS Variables**: Theme tokens in HSL format (see `index.css`) -- **Utility Function**: Use `cn()` from `@/lib/utils` for class merging -- **Dark Mode**: Default (controlled via HTML class) - -```typescript -// ✅ Styling pattern -import { cn } from "@/lib/utils"; - -const className = cn( - "flex items-center gap-2 p-2 rounded-lg", - isActive && "bg-primary text-primary-foreground", - className -); -``` - -### File Organization -- **By Domain**: Group files by feature/domain (`nostr/`, `ui/`, `services/`, `hooks/`) -- **Colocated Tests**: Test files next to source files (`*.test.ts`) -- **Barrel Exports**: Use `index.ts` for clean imports -- **Pure Functions**: Business logic in `src/core/logic.ts` - -### Naming Conventions -- **Components**: PascalCase with descriptive names (`LiveActivityRenderer`) -- **Hooks**: camelCase with `use` prefix (`useAccount`, `useProfile`) -- **Utilities**: camelCase with descriptive names (`getDisplayName`, `canAccountSign`) -- **Constants**: UPPER_SNAKE_CASE for exports -- **Files**: kebab-case for utilities, PascalCase for components - -## Testing Guidelines - -### Test Environment -- **Vitest**: Test framework with Node environment -- **Polyfills**: IndexedDB, WebSocket, localStorage pre-configured -- **Setup File**: `src/test/setup.ts` contains browser API polyfills - -### What to Test -- **Parsers**: All argument parsing logic and edge cases -- **Pure Functions**: State mutations and business logic -- **Utilities**: Helper functions and data transformations -- **Not UI Components**: React components tested manually - -### Test Structure -```typescript -describe("parseReqCommand", () => { - describe("kind flag (-k, --kind)", () => { - it("should parse single kind", () => { - const result = parseReqCommand(["-k", "1"]); - expect(result.filter.kinds).toEqual([1]); - }); - }); -}); -``` - -## Critical Rules - -1. **Singleton Services**: Never create new EventStore, RelayPool, or RelayLiveness instances -2. **Verification**: Always run full verification before committing changes -3. **Applesauce Caching**: Don't use `useMemo` with Applesauce helpers -4. **Account Signing**: Always check `canSign` before signing operations -5. **Error Boundaries**: Wrap event renderers in error boundaries -6. **Path Alias**: Use `@/` for all internal imports - -## Project Context - -Grimoire is a Nostr protocol explorer with a tiling window manager interface. Each window is a Nostr "app" (profile viewer, event feed, NIP documentation, etc.). Commands are launched Unix-style via Cmd+K palette. - -**Stack**: React 19 + TypeScript + Vite + TailwindCSS + Jotai + Dexie + Applesauce - ---- - -*See [CLAUDE.md](./CLAUDE.md) for detailed architecture, patterns, hooks, and conventions.* \ No newline at end of file diff --git a/APPLESAUCE_REFACTORING_PLAN.md b/APPLESAUCE_REFACTORING_PLAN.md deleted file mode 100644 index 9cc4830..0000000 --- a/APPLESAUCE_REFACTORING_PLAN.md +++ /dev/null @@ -1,379 +0,0 @@ -# Applesauce Helpers Refactoring Plan - -## Executive Summary - -After investigating applesauce-core helpers and grimoire's codebase, I've identified several opportunities to leverage applesauce's built-in helpers and caching mechanisms. **Key insight**: Applesauce helpers use internal caching via symbols (`getOrComputeCachedValue`), so we don't need `useMemo` when calling them. - -## Key Findings - -### 1. Applesauce Caching System - -Applesauce helpers cache computed values on event objects using symbols: -```typescript -// From applesauce-core/helpers/cache.d.ts -export declare function getOrComputeCachedValue( - event: any, - symbol: symbol, - compute: () => T -): T; -``` - -**Implication**: When you call helpers like `getArticleTitle(event)`, `getHighlightText(event)`, etc., the result is cached on the event object. Subsequent calls return the cached value instantly. **We don't need useMemo for applesauce helper calls.** - -### 2. Already Using Applesauce Helpers - -Grimoire already imports and uses many applesauce helpers correctly: -- ✅ `getTagValue` - used extensively in nip34-helpers.ts, nip-c0-helpers.ts -- ✅ `getNip10References` - used in nostr-utils.ts -- ✅ `getCommentReplyPointer` - used in nostr-utils.ts -- ✅ Article helpers - `getArticleTitle`, `getArticleSummary` (ArticleRenderer.tsx) -- ✅ Highlight helpers - all 6+ helpers used (HighlightRenderer.tsx, HighlightDetailRenderer.tsx) -- ✅ Code snippet helpers - imported from nip-c0-helpers.ts - -### 3. Applesauce Helpers Available but Not Used - -#### Profile Helpers -```typescript -// applesauce-core/helpers/profile -export function getDisplayName( - metadata: ProfileContent | NostrEvent | undefined, - fallback?: string -): string | undefined; -``` - -**Current grimoire code** (src/lib/nostr-utils.ts:65-76): -```typescript -export function getDisplayName( - pubkey: string, - metadata?: ProfileContent, -): string { - if (metadata?.display_name) return metadata.display_name; - if (metadata?.name) return metadata.name; - return derivePlaceholderName(pubkey); -} -``` - -**Issue**: Grimoire's version requires both `pubkey` and `metadata`, while applesauce only takes `metadata`. Our version adds fallback logic with `derivePlaceholderName`. - -**Recommendation**: Keep grimoire's version - it provides better UX with pubkey-based fallback. - -#### Pointer Helpers -```typescript -// applesauce-core/helpers/pointers -export function getEventPointerFromETag(tag: string[]): EventPointer; -export function getEventPointerFromQTag(tag: string[]): EventPointer; -export function getAddressPointerFromATag(tag: string[]): AddressPointer; -export function getProfilePointerFromPTag(tag: string[]): ProfilePointer; -export function parseCoordinate(a: string): AddressPointerWithoutD | null; -``` - -**Current usage** in ReactionRenderer.tsx:58-66: -```typescript -const addressParts = useMemo(() => { - if (!reactedAddress) return null; - const parts = reactedAddress.split(":"); - return { - kind: parseInt(parts[0], 10), - pubkey: parts[1], - dTag: parts[2], - }; -}, [reactedAddress]); -``` - -**Recommendation**: Replace manual parsing with `parseCoordinate` helper. - -#### Reaction Pointer Helpers -```typescript -// applesauce-core/helpers/reactions -export function getReactionEventPointer(event: NostrEvent): EventPointer | undefined; -export function getReactionAddressPointer(event: NostrEvent): AddressPointer | undefined; -``` - -**Current usage** in ReactionRenderer.tsx: Manual tag extraction and parsing. - -**Recommendation**: Use built-in reaction pointer helpers. - -#### Filter Comparison Helper -```typescript -// applesauce-core/helpers/filter -export function isFilterEqual( - a: FilterWithAnd | FilterWithAnd[], - b: FilterWithAnd | FilterWithAnd[] -): boolean; -``` - -**Current usage** in useStable.ts:55-58: -```typescript -export function useStableFilters(filters: T): T { - return useMemo(() => filters, [JSON.stringify(filters)]); -} -``` - -**Recommendation**: Replace JSON.stringify comparison with `isFilterEqual` for more robust filter comparison. - -### 4. Custom Helpers We Need to Keep - -These are **not** available in applesauce and provide grimoire-specific functionality: - -1. **`getTagValues` (plural)** - src/lib/nostr-utils.ts:59-63 - ```typescript - export function getTagValues(event: NostrEvent, tagName: string): string[] { - return event.tags - .filter((tag) => tag[0] === tagName && tag[1]) - .map((tag) => tag[1]); - } - ``` - **Keep**: Applesauce only has `getTagValue` (singular). We need the plural version. - -2. **`resolveFilterAliases`** - src/lib/nostr-utils.ts:85-156 - - Resolves `$me` and `$contacts` in filters - - Grimoire-specific feature - **Keep**: No applesauce equivalent. - -3. **NIP-34 helpers** - src/lib/nip34-helpers.ts - - Git repository, issue, patch, PR helpers - **Keep**: Grimoire-specific, uses `getTagValue` underneath. - -4. **NIP-C0 helpers** - src/lib/nip-c0-helpers.ts - - Code snippet helpers - **Keep**: Uses `getTagValue` underneath, grimoire-specific. - -5. **Custom event processing** - src/lib/spell-conversion.ts, spellbook-manager.ts - **Keep**: Grimoire-specific functionality. - -## Refactoring Opportunities - -### HIGH PRIORITY: Remove Unnecessary useMemo - -Since applesauce helpers cache internally, **remove useMemo from all applesauce helper calls**: - -#### 1. ArticleRenderer.tsx (lines 17-18) -```typescript -// BEFORE -const title = useMemo(() => getArticleTitle(event), [event]); -const summary = useMemo(() => getArticleSummary(event), [event]); - -// AFTER - helpers cache internally -const title = getArticleTitle(event); -const summary = getArticleSummary(event); -``` - -#### 2. HighlightRenderer.tsx (lines 24-36) + HighlightDetailRenderer.tsx (lines 22-35) -```typescript -// BEFORE -const highlightText = useMemo(() => getHighlightText(event), [event]); -const sourceUrl = useMemo(() => getHighlightSourceUrl(event), [event]); -const comment = useMemo(() => getHighlightComment(event), [event]); -const eventPointer = useMemo(() => getHighlightSourceEventPointer(event), [event]); -const addressPointer = useMemo(() => getHighlightSourceAddressPointer(event), [event]); -const context = useMemo(() => getHighlightContext(event), [event]); - -// AFTER - helpers cache internally -const highlightText = getHighlightText(event); -const sourceUrl = getHighlightSourceUrl(event); -const comment = getHighlightComment(event); -const eventPointer = getHighlightSourceEventPointer(event); -const addressPointer = getHighlightSourceAddressPointer(event); -const context = getHighlightContext(event); -``` - -#### 3. CodeSnippetDetailRenderer.tsx (lines 37-44) -```typescript -// BEFORE -const name = useMemo(() => getCodeName(event), [event]); -const language = useMemo(() => getCodeLanguage(event), [event]); -const extension = useMemo(() => getCodeExtension(event), [event]); -const description = useMemo(() => getCodeDescription(event), [event]); -const runtime = useMemo(() => getCodeRuntime(event), [event]); -const licenses = useMemo(() => getCodeLicenses(event), [event]); -const dependencies = useMemo(() => getCodeDependencies(event), [event]); -const repo = useMemo(() => getCodeRepo(event), [event]); - -// AFTER - our custom helpers use getTagValue which caches -const name = getCodeName(event); -const language = getCodeLanguage(event); -const extension = getCodeExtension(event); -const description = getCodeDescription(event); -const runtime = getCodeRuntime(event); -const licenses = getCodeLicenses(event); -const dependencies = getCodeDependencies(event); -const repo = getCodeRepo(event); -``` - -#### 4. ChatView.tsx (lines 94, 96) -```typescript -// BEFORE -const threadRefs = useMemo(() => getNip10References(event), [event]); -const qTagValue = useMemo(() => getTagValue(event, "q"), [event]); - -// AFTER - helpers cache internally -const threadRefs = getNip10References(event); -const qTagValue = getTagValue(event, "q"); -``` - -#### 5. LiveActivityRenderer.tsx (lines 20-22) -```typescript -// BEFORE - if using applesauce helpers -const activity = useMemo(() => parseLiveActivity(event), [event]); -const status = useMemo(() => getLiveStatus(event), [event]); -const hostPubkey = useMemo(() => getLiveHost(event), [event]); - -// AFTER - check if these use applesauce helpers internally -// If yes, remove useMemo. If no, keep as is. -``` - -**Note**: Check if `parseLiveActivity`, `getLiveStatus`, `getLiveHost` use applesauce helpers or implement caching themselves. - -### MEDIUM PRIORITY: Use Applesauce Pointer Helpers - -#### 1. ReactionRenderer.tsx - Replace manual coordinate parsing -```typescript -// BEFORE (lines 58-66) -const addressParts = useMemo(() => { - if (!reactedAddress) return null; - const parts = reactedAddress.split(":"); - return { - kind: parseInt(parts[0], 10), - pubkey: parts[1], - dTag: parts[2], - }; -}, [reactedAddress]); - -// AFTER - use parseCoordinate helper -import { parseCoordinate } from "applesauce-core/helpers"; - -const addressPointer = reactedAddress ? parseCoordinate(reactedAddress) : null; -// No useMemo needed - parseCoordinate is a simple function -``` - -#### 2. ReactionRenderer.tsx - Use reaction pointer helpers -```typescript -// CURRENT: Manual tag extraction -const reactedEventId = event.tags.find((t) => t[0] === "e")?.[1]; -const reactedAddress = event.tags.find((t) => t[0] === "a")?.[1]; - -// POTENTIAL ALTERNATIVE: Use built-in helpers -import { getReactionEventPointer, getReactionAddressPointer } from "applesauce-core/helpers"; - -const eventPointer = getReactionEventPointer(event); -const addressPointer = getReactionAddressPointer(event); -``` - -**Trade-off**: Current code gets raw values, helpers return typed pointers. May require component changes. **Evaluate if worth it.** - -### MEDIUM PRIORITY: Improve Filter Comparison - -#### useStable.ts - Use isFilterEqual instead of JSON.stringify -```typescript -// BEFORE (lines 55-58) -export function useStableFilters(filters: T): T { - return useMemo(() => filters, [JSON.stringify(filters)]); -} - -// AFTER - use isFilterEqual for comparison -import { isFilterEqual } from "applesauce-core/helpers"; - -export function useStableFilters(filters: T): T { - const prevFiltersRef = useRef(); - - if (!prevFiltersRef.current || !isFilterEqual(prevFiltersRef.current as any, filters as any)) { - prevFiltersRef.current = filters; - } - - return prevFiltersRef.current; -} -``` - -**Benefits**: -- More robust comparison (handles undefined values correctly) -- Avoids JSON serialization overhead -- Supports NIP-ND AND operator (`&` prefix) - -**Note**: May need to handle non-filter types (arrays, objects). - -### LOW PRIORITY: Code Organization - -#### 1. Document applesauce caching in code comments -Add comments to custom helpers that use applesauce helpers: - -```typescript -// nip34-helpers.ts -/** - * Get the repository name from a repository event - * Note: Uses applesauce getTagValue which caches internally - * @param event Repository event (kind 30617) - * @returns Repository name or undefined - */ -export function getRepositoryName(event: NostrEvent): string | undefined { - return getTagValue(event, "name"); -} -``` - -#### 2. Consider consolidating tag extraction -Since we use `getTagValue` extensively, ensure all single-tag extractions use it instead of manual `find()`: - -```typescript -// PREFER -const value = getTagValue(event, "tagName"); - -// AVOID -const value = event.tags.find(t => t[0] === "tagName")?.[1]; -``` - -## Testing Recommendations - -1. **Test helper caching**: Verify applesauce helpers actually cache (call twice, ensure same reference) -2. **Performance testing**: Measure before/after removing useMemo (expect minimal change due to caching) -3. **Filter comparison**: Test `isFilterEqual` edge cases (undefined, empty arrays, NIP-ND AND operator) -4. **Pointer parsing**: Test `parseCoordinate` with various coordinate formats - -## Migration Strategy - -### Phase 1: Remove Unnecessary useMemo (Low Risk) -1. Remove useMemo from applesauce helper calls in kind renderers -2. Test rendering performance -3. Verify no issues -4. Commit - -### Phase 2: Replace Pointer Parsing (Medium Risk) -1. Replace manual coordinate parsing with `parseCoordinate` -2. Update types if needed -3. Test reaction rendering -4. Commit - -### Phase 3: Improve Filter Comparison (Medium Risk) -1. Implement `useStableFilters` with `isFilterEqual` -2. Test filter subscription behavior -3. Verify no unnecessary re-subscriptions -4. Commit - -### Phase 4: Documentation (Low Risk) -1. Update CLAUDE.md with applesauce helper guidance -2. Add code comments documenting caching -3. Update skills if needed - -## Questions to Investigate - -1. **Do all our custom helpers cache?** Check `parseLiveActivity`, `getLiveStatus`, `getEventDisplayTitle`, etc. -2. **Should we create a shared cache util?** For custom helpers that don't use applesauce helpers underneath -3. **Is getTagValues used enough to add to applesauce?** Consider contributing upstream -4. **Filter aliases**: Could `resolveFilterAliases` be contributed to applesauce? - -## Summary of Changes - -| Category | Impact | Files Affected | Effort | -|----------|--------|----------------|--------| -| Remove useMemo from applesauce helpers | Performance (minor), Code clarity (major) | 8+ renderer files | Low | -| Use pointer helpers | Type safety, Code clarity | ReactionRenderer.tsx | Medium | -| Improve filter comparison | Correctness, Performance | useStable.ts | Medium | -| Documentation | Developer experience | CLAUDE.md, skills | Low | - -**Total estimated effort**: 4-6 hours -**Risk level**: Low-Medium -**Expected benefits**: Cleaner code, better alignment with applesauce patterns, easier maintenance - -## References - -- [Applesauce Documentation](https://hzrd149.github.io/applesauce/) -- [Applesauce GitHub](https://github.com/hzrd149/applesauce) -- [Applesauce Core TypeDoc](https://hzrd149.github.io/applesauce/typedoc/modules/applesauce-core.html) diff --git a/CODEBASE_ANALYSIS.md b/CODEBASE_ANALYSIS.md deleted file mode 100644 index 8363c8d..0000000 --- a/CODEBASE_ANALYSIS.md +++ /dev/null @@ -1,650 +0,0 @@ -# Grimoire Codebase Analysis & S-Tier Quality Plan - -**Analysis Date**: December 2025 -**Codebase Size**: ~28,500 lines of TypeScript across 240 files -**Stack**: React 19 + TypeScript 5.6 + Vite 6 + TailwindCSS + Jotai + Dexie + Applesauce - ---- - -## Executive Summary - -Grimoire is a **well-architected Nostr protocol explorer** with a unique tiling window manager interface. The codebase demonstrates strong engineering fundamentals with thoughtful patterns, comprehensive testing of core logic, and modern React practices. However, several areas require attention to reach S-tier quality. - -### Current Quality Assessment - -| Category | Grade | Summary | -|----------|-------|---------| -| **Architecture** | A- | Clean separation, singleton patterns, reactive data flow | -| **Code Quality** | B+ | Strong patterns with some duplication and inconsistencies | -| **Performance** | B+ | Good optimizations, but gaps in memoization | -| **Security** | A | Zero vulnerabilities, proper input validation | -| **Testing** | B | Excellent parser coverage, gaps in components/hooks | -| **Accessibility** | C+ | Foundation present, sparse coverage | -| **UX** | B | Desktop-first, keyboard-driven, limited mobile support | -| **Documentation** | B- | Good inline docs, missing API documentation | - ---- - -## Part 1: Architecture Analysis - -### Strengths - -#### 1. Tri-Partite State Management -The separation of concerns across three state systems is excellent: - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ STATE ARCHITECTURE │ -├──────────────────────┬──────────────────┬──────────────────────┤ -│ UI State (Jotai) │ Nostr State │ Relay/DB State │ -│ ├─ Workspaces │ (EventStore) │ (RelayLiveness) │ -│ ├─ Windows │ ├─ Events │ ├─ Connection state │ -│ ├─ Layout tree │ ├─ Profiles │ ├─ Auth preferences │ -│ └─ Active account │ └─ Replaceables │ └─ Backoff tracking │ -│ │ │ │ -│ localStorage │ In-memory RxJS │ IndexedDB (Dexie) │ -└──────────────────────┴──────────────────┴──────────────────────┘ -``` - -#### 2. Pure Function State Mutations (`src/core/logic.ts`) -All UI state mutations follow a pure function pattern: -```typescript -export const addWindow = (state: GrimoireState, payload: AddWindowPayload): GrimoireState => ({ - ...state, - windows: { ...state.windows, [window.id]: window }, - // ...immutable updates -}); -``` - -**Benefits**: Easily testable, predictable, no side effects - -#### 3. Singleton Pattern for Services -Critical services use singletons preventing resource duplication: -- `EventStore` - Single source of truth for Nostr events -- `RelayPool` - Reuses WebSocket connections -- `RelayLiveness` - Centralized health tracking -- `RelayStateManager` - Global connection + auth state - -#### 4. Reactive Data Flow -Applesauce + RxJS provides elegant reactive patterns: -```typescript -// Events flow: Relay → EventStore → Observable → Hook → Component -const events = useTimeline(filters, relays); // Auto-updates on new events -``` - -#### 5. Command System Design -Unix-style man pages with async parsers: -```typescript -manPages: { - req: { - synopsis: "req [options] [relay...]", - argParser: async (args) => parseReqCommand(args), - appId: "req" - } -} -``` - -### Weaknesses - -#### 1. Disconnected State Systems -- UI state (Jotai) doesn't know about relay health -- Manual sync points (`useAccountSync`, `useRelayState`) create coupling -- No unified error aggregation across systems - -#### 2. Race Conditions -```typescript -// useProfile.ts - async DB write can outlive component -const sub = profileLoader(...).subscribe({ - next: async (event) => { - await db.profiles.put(...); // Component may unmount during await - if (mounted) setProfile(...); - } -}); -``` - -#### 3. Polling Instead of Events -```typescript -// RelayStateManager polls every 1 second -this.pollingIntervalId = setInterval(() => { - pool.relays.forEach(relay => { - if (!this.subscriptions.has(relay.url)) { - this.monitorRelay(relay); - } - }); -}, 1000); -``` - -#### 4. No Memory Bounds on EventStore -EventStore can grow unbounded with continuous streams. No LRU eviction or max size. - ---- - -## Part 2: Code Quality Analysis - -### Strengths - -#### 1. Kind Renderer Registry Pattern -Scalable dispatch without conditionals: -```typescript -const kindRenderers: Record = { - 0: ProfileRenderer, - 1: NoteRenderer, - // 40+ kinds... -}; - -export function KindRenderer({ event }) { - const Renderer = kindRenderers[event.kind] || DefaultKindRenderer; - return ; -} -``` - -#### 2. Error Boundary Strategy -Three-tier isolation prevents cascading failures: -- **App-level**: Full recovery UI with reload options -- **Window-level**: Close broken window, others continue -- **Event-level**: Single event fails, feed continues - -#### 3. Dependency Stabilization Pattern -Prevents infinite render loops in hooks: -```typescript -const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]); -const stableRelays = useMemo(() => relays, [relays.join(",")]); -``` - -### Weaknesses - -#### 1. Code Duplication - -**Replaceable Event Constants** (duplicated in 2 files): -```typescript -// Both BaseEventRenderer.tsx and KindRenderer.tsx define: -const REPLACEABLE_START = 10000; -const REPLACEABLE_END = 20000; -const PARAMETERIZED_REPLACEABLE_START = 30000; -``` - -**Replaceable Detection Logic** (repeated 3+ times): -```typescript -const isAddressable = - (event.kind >= REPLACEABLE_START && event.kind < REPLACEABLE_END) || - (event.kind >= PARAMETERIZED_REPLACEABLE_START && ...); -``` - -**Dependency Stabilization** (in 4+ hooks): -```typescript -// Identical pattern in useTimeline, useReqTimeline, useLiveTimeline, useOutboxRelays -const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]); -``` - -#### 2. Inconsistent Memoization -- Only 14/40+ kind renderers use `useMemo` -- Event handlers rarely wrapped in `useCallback` -- Creates unnecessary re-renders in virtualized lists - -#### 3. Type Safety Gaps -```typescript -// Scattered `as any` casts -(args as any) // CommandLauncher.tsx:81 -``` - -#### 4. Dead Code -```typescript -// BaseEventRenderer.tsx has large commented-out blocks -// import { kinds } from "nostr-tools"; -// ... commented compact mode code -``` - ---- - -## Part 3: Performance Analysis - -### Strengths - -#### 1. Strategic Code Splitting -```typescript -// vite.config.ts -manualChunks: { - 'react-vendor': ['react', 'react-dom'], - 'ui': ['@radix-ui/*', 'react-mosaic-component'], - 'nostr': ['applesauce-*', 'nostr-tools', 'rxjs', 'dexie'], - 'markdown': ['react-markdown', 'remark-gfm'] -} -``` - -#### 2. Virtual Scrolling -- `react-virtuoso` for large event feeds -- Handles 1000+ events efficiently - -#### 3. Lazy Loading -```typescript -const ProfileViewer = lazy(() => import("./ProfileViewer")); -// All viewers lazy-loaded with Suspense fallback -``` - -#### 4. Network Efficiency -- Connection pooling via RelayPool singleton -- Relay liveness prevents dead relay connections -- Aggregator fallback for event discovery - -### Weaknesses - -#### 1. JSON.stringify in Dependencies -```typescript -// O(n) serialization on every render -useMemo(() => filters, [JSON.stringify(filters)]); -``` - -#### 2. Missing useMemo in Renderers -Expensive operations computed on every render: -- `formatTimestamp()` called repeatedly -- Event content parsing without memoization -- Profile data extraction - -#### 3. No Performance Monitoring -- No web vitals tracking -- No performance budgets in build -- No Lighthouse CI integration - ---- - -## Part 4: Security Analysis - -### Strengths (Zero Critical Issues) - -| Check | Status | Details | -|-------|--------|---------| -| XSS Prevention | ✅ | No `dangerouslySetInnerHTML`, `skipHtml` enabled in markdown | -| Input Validation | ✅ | Regex patterns on NIP-05, URL normalization, title sanitization | -| Dependency Security | ✅ | `npm audit` returns 0 vulnerabilities | -| Memory Safety | ✅ | Proper subscription cleanup in all hooks | -| Cryptography | ✅ | Delegated to trusted libraries (nostr-tools, applesauce) | - -### Minor Concerns - -1. **localStorage Usage**: Account metadata stored in world-readable localStorage (by design - no private keys) -2. **No CSP Header**: Consider adding Content-Security-Policy meta tag - ---- - -## Part 5: Testing Analysis - -### Current Coverage - -| Category | Files | Coverage | Quality | -|----------|-------|----------|---------| -| Parsers | 7 | Excellent | ~95% edge cases | -| State Logic | 1 | Comprehensive | All mutations tested | -| Utilities | 11 | Good | Core paths covered | -| Services | 2 | Moderate | Selection logic tested | -| Components | 0 | None | Manual testing only | -| Hooks | 0 | None | No subscription tests | - -### Test Files (18 total) -``` -src/lib/req-parser.test.ts # Most comprehensive (600+ lines) -src/lib/command-parser.test.ts # Command parsing -src/lib/global-flags.test.ts # Flag extraction -src/core/logic.test.ts # State mutations -src/lib/migrations.test.ts # Schema migrations -... (13 more utility tests) -``` - -### Gaps - -1. **No component tests** - All React components tested manually -2. **No hook tests** - Subscription cleanup not verified -3. **No integration tests** - End-to-end flows untested -4. **No error boundary tests** - Recovery paths untested - ---- - -## Part 6: Accessibility Analysis - -### Strengths - -- **Keyboard Navigation**: Cmd+K palette, arrow keys, Enter/Escape -- **ARIA Labels**: 25 files with `aria-*` attributes -- **Focus Management**: Visible focus rings with proper styling -- **Screen Reader Support**: `VisuallyHidden` component, `sr-only` classes -- **Loading States**: Skeletons with `role="status"` and `aria-busy` - -### Weaknesses (Grade: C+) - -| Issue | Impact | Current State | -|-------|--------|---------------| -| Sparse ARIA coverage | High | Only 16% of components have ARIA | -| No form validation feedback | Medium | Errors not associated with inputs | -| No high contrast mode | Medium | Single theme only | -| Limited mobile support | High | Tiling UI unsuitable for touch | -| No live regions | Medium | Dynamic updates not announced | -| Missing keyboard legend | Low | Advanced shortcuts hidden | - ---- - -## Part 7: UX Analysis - -### Strengths - -1. **Power User Focus**: Unix-style commands, keyboard-driven -2. **Error Recovery**: Clear error states with retry options -3. **Skeleton Loading**: Context-appropriate loading placeholders -4. **Dark Mode Default**: Respects modern preferences -5. **Workspace System**: Virtual desktops with persistence - -### Weaknesses - -1. **Desktop Only**: Tiling window manager not suited for mobile -2. **Learning Curve**: No onboarding or tutorials -3. **Discovery**: Advanced features not discoverable -4. **No Undo**: Destructive actions (close window) not undoable - ---- - -## Part 8: S-Tier Improvement Plan - -### Phase 1: Critical Fixes (Week 1-2) - -#### 1.1 Extract Shared Constants -```typescript -// NEW FILE: src/lib/nostr-constants.ts -export const REPLACEABLE_START = 10000; -export const REPLACEABLE_END = 20000; -export const EPHEMERAL_START = 20000; -export const EPHEMERAL_END = 30000; -export const PARAMETERIZED_REPLACEABLE_START = 30000; -export const PARAMETERIZED_REPLACEABLE_END = 40000; - -export function isReplaceableKind(kind: number): boolean { - return (kind >= REPLACEABLE_START && kind < REPLACEABLE_END) || - (kind >= PARAMETERIZED_REPLACEABLE_START && kind < PARAMETERIZED_REPLACEABLE_END); -} -``` - -#### 1.2 Create Dependency Stabilization Hook -```typescript -// NEW FILE: src/hooks/useStable.ts -export function useStableValue(value: T, serialize?: (v: T) => string): T { - const serialized = serialize?.(value) ?? JSON.stringify(value); - return useMemo(() => value, [serialized]); -} - -export function useStableArray(arr: T[]): T[] { - return useMemo(() => arr, [arr.join(",")]); -} -``` - -#### 1.3 Fix Race Conditions -```typescript -// useProfile.ts - use AbortController pattern -useEffect(() => { - const controller = new AbortController(); - - const sub = profileLoader(...).subscribe({ - next: async (event) => { - if (controller.signal.aborted) return; - await db.profiles.put(...); - if (!controller.signal.aborted) setProfile(...); - } - }); - - return () => { - controller.abort(); - sub.unsubscribe(); - }; -}, [pubkey]); -``` - -#### 1.4 Replace Polling with Events -```typescript -// RelayStateManager - use pool events instead of setInterval -pool.on('relay:add', (relay) => this.monitorRelay(relay)); -pool.on('relay:remove', (url) => this.unmonitorRelay(url)); -``` - -### Phase 2: Performance Optimization (Week 3-4) - -#### 2.1 Add useMemo to Kind Renderers -Audit all 40+ kind renderers and add memoization for: -- Content parsing -- Tag extraction -- Formatting operations - -#### 2.2 Memoize Event Handlers -```typescript -// Wrap handlers passed to memoized children -const handleReplyClick = useCallback(() => { - addWindow("open", { pointer: replyPointer }); -}, [replyPointer, addWindow]); -``` - -#### 2.3 Add EventStore Memory Bounds -```typescript -// Configure max events with LRU eviction -const eventStore = new EventStore({ - maxEvents: 10000, - evictionPolicy: 'lru' -}); -``` - -#### 2.4 Implement Performance Monitoring -```bash -npm install web-vitals -``` -```typescript -// src/lib/analytics.ts -import { onCLS, onFID, onLCP } from 'web-vitals'; - -export function initPerformanceMonitoring() { - onCLS(console.log); - onFID(console.log); - onLCP(console.log); -} -``` - -### Phase 3: Testing Excellence (Week 5-6) - -#### 3.1 Component Testing Setup -```bash -npm install -D @testing-library/react @testing-library/jest-dom -``` - -#### 3.2 Add Hook Tests -```typescript -// src/hooks/useProfile.test.ts -describe('useProfile', () => { - it('should clean up subscription on unmount', async () => { - const { unmount } = renderHook(() => useProfile('pubkey')); - unmount(); - // Verify no memory leaks - }); - - it('should handle race conditions', async () => { - // Rapid mount/unmount should not cause errors - }); -}); -``` - -#### 3.3 Error Boundary Tests -```typescript -describe('EventErrorBoundary', () => { - it('should catch render errors', () => {...}); - it('should reset on event change', () => {...}); - it('should show retry button', () => {...}); -}); -``` - -#### 3.4 Integration Tests -```typescript -// src/__tests__/integration/command-flow.test.tsx -describe('Command Flow', () => { - it('should parse command and open window', async () => { - // Type "profile alice" → verify window opens - }); -}); -``` - -### Phase 4: Accessibility (Week 7-8) - -#### 4.1 Audit Tool Integration -```bash -npm install -D @axe-core/react -``` -```typescript -// Development-only accessibility audit -if (process.env.NODE_ENV === 'development') { - import('@axe-core/react').then(axe => { - axe.default(React, ReactDOM, 1000); - }); -} -``` - -#### 4.2 Form Error Pattern -```typescript -// Create consistent error association - -{error && ( - - {error} - -)} -``` - -#### 4.3 Live Regions -```typescript -// Announce dynamic updates -
- {statusMessage} -
-``` - -#### 4.4 Keyboard Shortcut Help -```typescript -// Add discoverable shortcut modal (Cmd+?) -const shortcuts = [ - { keys: ['⌘', 'K'], description: 'Open command palette' }, - { keys: ['⌘', '1-9'], description: 'Switch workspace' }, - // ... -]; -``` - -### Phase 5: UX Enhancements (Week 9-10) - -#### 5.1 Onboarding Flow -```typescript -// First-time user experience -const GrimoireWelcome = () => ( - - -

Welcome to Grimoire

-

Press ⌘K to get started...

- -
-
-); -``` - -#### 5.2 Undo System -```typescript -// Track recent actions for undo -const undoStack = atom([]); - -export function addWindow(state, payload) { - pushUndo({ type: 'ADD_WINDOW', windowId: window.id }); - return { ...state, ... }; -} - -export function undo(state) { - const action = popUndo(); - // Reverse the action -} -``` - -#### 5.3 Mobile Detection -```typescript -// Show appropriate message on mobile -const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent); - -if (isMobile) { - return ; -} -``` - -### Phase 6: Documentation & Polish (Week 11-12) - -#### 6.1 API Documentation -```typescript -/** - * Parse a REQ command string into filter and relay configuration - * - * @param args - Tokenized command arguments - * @returns ParsedReqCommand with filter, relays, and resolution metadata - * - * @example - * parseReqCommand(["-k", "1", "-a", "npub1..."]); - * // Returns: { filter: { kinds: [1], authors: ["hex..."] }, ... } - */ -export function parseReqCommand(args: string[]): ParsedReqCommand -``` - -#### 6.2 Architecture Documentation -Create `docs/ARCHITECTURE.md` with: -- State management diagram -- Data flow documentation -- Service interaction patterns - -#### 6.3 Remove Dead Code -- Delete commented code blocks -- Remove unused imports -- Clean up TODO/FIXME comments - -#### 6.4 Add CI/CD Quality Gates -```yaml -# .github/workflows/quality.yml -- run: npm run lint -- run: npm run test:run -- run: npm run build -- run: npx lighthouse-ci -``` - ---- - -## Priority Matrix - -| Priority | Items | Effort | Impact | -|----------|-------|--------|--------| -| **P0 Critical** | Race condition fixes, memory bounds | Medium | High | -| **P1 High** | Code deduplication, memoization | Low | High | -| **P2 Medium** | Testing expansion, accessibility | High | High | -| **P3 Low** | UX polish, documentation | Medium | Medium | - ---- - -## Success Metrics for S-Tier - -| Metric | Current | Target | -|--------|---------|--------| -| Lighthouse Performance | ~75 | 95+ | -| Lighthouse Accessibility | ~60 | 95+ | -| Test Coverage | ~40% | 80%+ | -| Code Duplication | ~5% | <2% | -| npm audit vulnerabilities | 0 | 0 | -| Core Web Vitals | Unknown | All "Good" | -| TypeScript Strict | Yes | Yes | -| ARIA Coverage | 16% | 90%+ | - ---- - -## Conclusion - -Grimoire is a **solid B+ codebase** with excellent architecture fundamentals and security posture. The path to S-tier requires: - -1. **Immediate**: Fix race conditions, extract shared code -2. **Short-term**: Add memoization, expand testing -3. **Medium-term**: Accessibility audit, UX improvements -4. **Long-term**: Documentation, CI/CD quality gates - -The codebase is well-positioned for these improvements - the architecture is sound, patterns are consistent, and the team clearly values quality. With focused effort on the gaps identified, Grimoire can reach S-tier status. diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 45a8bd7..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,93 +0,0 @@ -# GEMINI.md - -This file provides context and guidance for Gemini (and other AI agents) when working with the Grimoire repository. - -## Project Overview - -Grimoire is a Nostr protocol explorer and developer tool. It features a tiling window manager interface where each window is a Nostr "app" (profile viewer, event feed, NIP documentation, etc.). Commands are launched Unix-style via a `Cmd+K` palette. - -**Stack:** React 19 + TypeScript + Vite + TailwindCSS + Jotai + Dexie + Applesauce - -## Core Architecture - -### 1. Dual State System - -* **UI State** (`src/core/state.ts` + `src/core/logic.ts`): - * Managed by **Jotai** atoms, persisted to `localStorage`. - * **Mutations:** strict adherence to pure functions in `src/core/logic.ts` (`(state, payload) => newState`). - * **Scope:** Workspaces, windows, layout tree, active account. - -* **Nostr State** (`src/services/event-store.ts`): - * **Singleton `EventStore`** from `applesauce-core`. - * Single source of truth for all Nostr events. - * Reactive: Components subscribe via hooks (`useProfile`, `useTimeline`, `useNostrEvent`). - * **CRITICAL:** Do NOT create new `EventStore` instances. Use the singleton in `src/services/`. - -* **Relay State** (`src/services/relay-liveness.ts`): - * Singleton `RelayLiveness` tracks relay health. - * Persisted to Dexie. - -### 2. Window System - -* **Layout:** Recursive binary split layout via `react-mosaic-component`. -* **Structure:** - * **Leaf:** Window ID (UUID). - * **Branch:** Split space. -* **Constraint:** **Never manipulate the layout tree directly.** Use `updateLayout()` callbacks or `logic.ts` helpers. -* **Window Props:** `id`, `appId` (type identifier), `title`, `props`. - -### 3. Command System - -* **Definition:** `src/types/man.ts` defines commands as Unix man pages. -* **Flow:** User types command -> `argParser` resolves props -> Helper opens specific `appId` viewer. -* **Global Flags:** Defined in `src/lib/global-flags.ts` (e.g., `--title` overrides window title). - -### 4. Reactive Nostr Pattern - -* **Flow:** Relays -> EventStore -> Observables -> Component Hooks. -* **Helpers:** Use `applesauce-react` hooks or custom hooks in `src/hooks/`. -* **Replaceable Events:** Handled automatically by EventStore (kinds 0, 3, 10000+, 30000+). - -## Key Conventions - -* **Path Alias:** `@/` maps to `./src/`. -* **Styling:** TailwindCSS + HSL variables for theming (defined in `index.css`). -* **Organization:** Domain-based (`nostr/`, `ui/`, `services/`, `hooks/`, `lib/`). -* **Types:** Prefer `applesauce-core` types; extend in `src/types/`. - -## Important Patterns - -### Adding New Commands -1. Add entry to `manPages` in `src/types/man.ts`. -2. Create argument parser in `src/lib/*-parser.ts`. -3. Create viewer component for the `appId`. -4. Register viewer in `WindowTitle.tsx` (or equivalent registry). - -### Event Rendering -* **Pattern:** Registry-based rendering. -* **Files:** `src/components/nostr/kinds/index.tsx`. -* **Components:** `KindRenderer` (feed) and `DetailKindRenderer` (detail/full view). -* **Naming:** Use descriptive names (`LiveActivityRenderer`) not numbers (`Kind30311Renderer`). -* **Safety:** All renderers are wrapped in `EventErrorBoundary`. - -### Testing -* **Framework:** Vitest. -* **Commands:** - * `npm test`: Watch mode. - * `npm run test:run`: CI mode (single run). -* **Focus:** Test pure functions in `logic.ts`, parsers in `lib/*-parser.ts`, and utilities. - -## Critical Rules for Agents - -> [!IMPORTANT] -> **Do NOT create new instances of singletons.** -> * `EventStore`, `RelayPool`, `RelayLiveness` are singletons in `src/services/`. - -> [!IMPORTANT] -> **Respect the Layout Tree.** -> * Do not manually traverse or modify the Mosaic layout object. Use specific update callbacks. - -> [!NOTE] -> **Use the Knowledge Base.** -> * Refer to `CLAUDE.md` for the original documentation source. -> * Check `.claude/skills` for library-specific documentation (Applesauce, Nostr tools). diff --git a/PLAN-APPLESAUCE-V5.md b/PLAN-APPLESAUCE-V5.md deleted file mode 100644 index f802e7c..0000000 --- a/PLAN-APPLESAUCE-V5.md +++ /dev/null @@ -1,427 +0,0 @@ -# Applesauce v5 Upgrade Plan for Grimoire - -## Overview - -This plan outlines the migration from applesauce v4 to v5, covering breaking changes, new features to adopt, and documentation updates. - -## Phase 1: Package Updates & Import Migration - -### 1.1 Update package.json Dependencies - -**Current versions:** -```json -"applesauce-accounts": "^4.1.0", -"applesauce-actions": "^4.0.0", -"applesauce-content": "^4.0.0", -"applesauce-core": "latest", -"applesauce-loaders": "^4.2.0", -"applesauce-react": "^4.0.0", -"applesauce-relay": "latest", -"applesauce-signers": "^4.1.0" -``` - -**Target versions:** -```json -"applesauce-accounts": "^5.0.0", -"applesauce-actions": "^5.0.0", -"applesauce-common": "^5.0.0", // NEW - required for v5 -"applesauce-content": "^5.0.0", -"applesauce-core": "^5.0.0", -"applesauce-loaders": "^5.0.0", -"applesauce-react": "^5.0.0", -"applesauce-relay": "^5.0.0", -"applesauce-signers": "^5.0.0" -``` - -**Remove:** -```json -"applesauce-factory": "..." // Removed in v5 - EventFactory now in applesauce-core -``` - -### 1.2 EventFactory Import Migration - -**File:** `src/services/hub.ts` - -```typescript -// Before (v4) -import { EventFactory } from "applesauce-factory"; - -// After (v5) -import { EventFactory } from "applesauce-core/event-factory"; -``` - -### 1.3 Helper Import Migration - -Many helpers moved from `applesauce-core/helpers` to `applesauce-common/helpers`. Need to audit all imports: - -**Helpers that moved to applesauce-common/helpers:** -- Profile helpers: `getDisplayName`, `getProfilePicture` -- Social graph: `groupPubkeysByRelay`, `getSeenRelays` -- Threading: `getNip10References`, `interpretThreadTags` -- Zaps: `getZapAmount`, `getZapSender`, `isValidZap` -- Lists: `FAVORITE_RELAYS_KIND`, `getListTags`, `getRelaysFromList` -- Article helpers: `getArticleTitle`, `getArticleSummary`, etc. -- Highlight helpers: all `getHighlight*` functions -- Comment helpers: `getCommentReplyPointer` - -**Files to update (from exploration):** -- `src/lib/nostr-utils.ts` -- `src/lib/event-title.ts` -- `src/lib/nip34-helpers.ts` -- `src/lib/nip-c0-helpers.ts` -- `src/services/loaders.ts` -- `src/services/relay-selection.ts` -- `src/hooks/useProfile.ts` -- `src/hooks/useNostrEvent.ts` -- `src/components/nostr/RichText.tsx` -- All kind renderers in `src/components/nostr/kinds/` - -**Strategy:** Use grep to find all `applesauce-core/helpers` imports and update to `applesauce-common/helpers` where appropriate. Some low-level helpers remain in `applesauce-core/helpers`. - ---- - -## Phase 2: Loader Migration (Unified Event Loader) - -### 2.1 Current Loader Architecture - -**File:** `src/services/loaders.ts` - -Currently uses: -- `createEventLoader` - for single events -- `createAddressLoader` - for replaceable/addressable events -- `createTimelineLoader` - for timelines -- Custom `eventLoader` wrapper with smart relay hint merging - -### 2.2 Unified Loader Setup - -Replace separate loaders with unified loader: - -```typescript -// Before (v4) -import { - createEventLoader, - createAddressLoader, - createTimelineLoader, -} from "applesauce-loaders/loaders"; - -const baseEventLoader = createEventLoader(pool, { eventStore, extraRelays }); -export const addressLoader = createAddressLoader(pool, { eventStore, extraRelays }); -export const profileLoader = createAddressLoader(pool, { eventStore, bufferTime: 200, extraRelays }); - -// After (v5) -import { createEventLoaderForStore } from "applesauce-loaders/loaders"; - -// One-time setup - attaches loader to eventStore -createEventLoaderForStore(eventStore, pool, { - bufferTime: 200, - followRelayHints: true, - extraRelays: AGGREGATOR_RELAYS, - lookupRelays: ["wss://purplepag.es/", "wss://index.hzrd149.com/"], -}); - -// Usage changes - use eventStore methods directly -eventStore.event({ id: "event_id" }).subscribe(...); -eventStore.replaceable({ kind: 0, pubkey: "pubkey" }).subscribe(...); -eventStore.addressable({ kind: 30023, pubkey, identifier }).subscribe(...); -``` - -### 2.3 Custom eventLoader Wrapper - -The current smart relay hint merging in `eventLoader()` needs to be preserved. Options: - -**Option A:** Keep custom wrapper, use unified loader internally -```typescript -export function eventLoader(pointer, context) { - // Keep existing relay hint merging logic - const enhancedPointer = mergeRelayHints(pointer, context); - - // Use eventStore.event() from unified loader - return eventStore.event(enhancedPointer); -} -``` - -**Option B:** Move relay hint logic to a cacheRequest/extraRelays callback -```typescript -createEventLoaderForStore(eventStore, pool, { - bufferTime: 200, - followRelayHints: true, - extraRelays: (pointer) => getRelayHintsForPointer(pointer), -}); -``` - -**Recommendation:** Option A - Keep custom wrapper for backward compatibility and control over relay selection. - -### 2.4 Hook Updates for Unified Loader - -**Files to update:** -- `src/hooks/useNostrEvent.ts` - Use eventStore.event() / eventStore.replaceable() -- `src/hooks/useProfile.ts` - Use eventStore.replaceable() for kind 0 -- `src/hooks/useTimeline.ts` - May continue using createTimelineLoader -- `src/hooks/useLiveTimeline.ts` - No change (uses pool.subscription directly) -- `src/hooks/useAccountSync.ts` - Use eventStore.addressable() for kind 10002 - ---- - -## Phase 3: Action System Migration (ActionHub → ActionRunner) - -### 3.1 Breaking Changes - -**Before (v4):** Actions are async generators that `yield` events -```typescript -export function PublishSpellbook(options) { - return async function* ({ factory }: ActionContext): AsyncGenerator { - const draft = await factory.build({ kind, content, tags }); - const event = await factory.sign(draft); - yield event; // ActionHub handles publishing - }; -} -``` - -**After (v5):** Actions are async functions that call `context.publish()` -```typescript -export function PublishSpellbook(options) { - return async function ({ factory, publish }: ActionContext): Promise { - const draft = await factory.build({ kind, content, tags }); - const event = await factory.sign(draft); - await publish(event); // Explicit publish call - }; -} -``` - -### 3.2 Files to Migrate - -1. **`src/services/hub.ts`:** - ```typescript - // Before - import { ActionHub } from "applesauce-actions"; - export const hub = new ActionHub(eventStore, factory, publishEvent); - - // After - import { ActionRunner } from "applesauce-actions"; - export const hub = new ActionRunner(eventStore, factory, publishEvent); - ``` - -2. **`src/actions/publish-spellbook.ts`:** - - Convert from async generator to async function - - Replace `yield event` with `await publish(event)` - -3. **`src/actions/publish-spell.ts`:** - - Same conversion pattern - -4. **`src/actions/delete-event.ts`:** - - Already uses direct factory.sign + pool.publish pattern - - Consider migrating to ActionRunner for consistency - -### 3.3 Action Context Changes - -v5 ActionContext provides additional features: -- `publish(event, relays?)` - Explicit publish with optional relay override -- `cast(event, Cast)` - Cast events to typed classes -- Sub-action support for composing actions - ---- - -## Phase 4: React Hooks Migration (use$ Hook) - -### 4.1 New use$ Hook - -Replace `useObservableMemo` with `use$`: - -```typescript -// Before (v4) -import { useObservableMemo } from "applesauce-react/hooks"; - -const event = useObservableMemo(() => eventStore.event(eventId), [eventId]); -const activeAccount = useObservableMemo(() => accounts.active$, []); - -// After (v5) -import { use$ } from "applesauce-react/hooks"; - -const event = use$(() => eventStore.event(eventId), [eventId]); -const activeAccount = use$(accounts.active$); // Direct observable, no factory needed -``` - -### 4.2 Files to Update - -Search for all `useObservableMemo` imports and update: -- `src/hooks/useNostrEvent.ts` -- `src/hooks/useTimeline.ts` -- `src/hooks/useLiveTimeline.ts` -- `src/hooks/useAccountSync.ts` -- `src/hooks/useStable.ts` -- Any component using `useObservableMemo` directly - -### 4.3 Type Differences - -`use$` has better TypeScript inference: -- `BehaviorSubject` → returns `T` (never undefined) -- `Observable` → returns `T | undefined` - ---- - -## Phase 5: Adopt New Features - -### 5.1 Casting System - -Add casting for improved type safety and reactive patterns: - -```typescript -import { castEvent, Note, User, Profile } from "applesauce-common/casts"; - -// Cast events to typed classes -const note = castEvent(event, Note, eventStore); - -// Synchronous properties -console.log(note.id, note.createdAt, note.isReply); - -// Reactive observables -const profile = use$(note.author.profile$); -const replies = use$(note.replies$); -``` - -**Potential use cases in Grimoire:** -- Note renderers - cast kind 1 events for cleaner access -- Profile components - use `User` cast for profile data -- Reply threads - use reactive `replies$` observable -- Zap displays - cast for amount/sender access - -### 5.2 Encrypted Content Caching - -For DM/encrypted content features: - -```typescript -import { persistEncryptedContent } from "applesauce-common/helpers"; - -// Setup in services/event-store.ts or main.tsx -persistEncryptedContent(eventStore, storage); - -// Decrypted content automatically cached -await unlockHiddenBookmarks(bookmarks, signer); -``` - -### 5.3 Blueprints and Operations - -Move event creation to blueprints for cleaner code: - -```typescript -// Before - manual event building -const draft = await factory.build({ - kind: 30777, - content: JSON.stringify(content), - tags: [["d", slug], ["title", title]] -}); - -// After - using blueprints (if available for kind 30777) -import { SpellbookBlueprint } from "applesauce-common/blueprints"; -const draft = await factory.build(SpellbookBlueprint({ title, content, slug })); -``` - -Note: Custom kinds like 30777 may not have built-in blueprints, but the pattern can be used for standard kinds. - ---- - -## Phase 6: Documentation Updates - -### 6.1 CLAUDE.md Updates - -Update the following sections: - -1. **Package structure** - Add `applesauce-common` to stack description -2. **Import patterns** - Update helper import paths -3. **Loader documentation** - Document unified loader pattern -4. **Action documentation** - Document ActionRunner pattern -5. **Hook documentation** - Document `use$` hook -6. **New features** - Add casting system documentation -7. **Helper caching note** - Update import paths in examples - -### 6.2 Skill Updates - -**`.claude/skills/applesauce-core/SKILL.md`:** -- Update import paths for helpers that moved to applesauce-common -- Add section on unified event loader -- Update examples to use `use$` hook -- Add casting system documentation -- Note v5 breaking changes - -**New skill: `.claude/skills/applesauce-common/SKILL.md`:** -- Document casting system (Note, User, Profile, etc.) -- Document helpers that moved from applesauce-core -- Document blueprints and operations -- Document encrypted content caching - -**`.claude/skills/applesauce-signers/SKILL.md`:** -- Verify no breaking changes (appears stable) -- Update if any signer interface changes - -### 6.3 Code Comments - -Update inline comments in affected files to reflect v5 patterns. - ---- - -## Phase 7: Testing & Verification - -### 7.1 Test Updates - -Update test files for new patterns: -- `src/actions/publish-spell.test.ts` -- `src/actions/publish-spellbook.test.ts` -- Any tests using mocked applesauce imports - -### 7.2 Verification Steps - -1. Run `npm install` after package.json updates -2. Fix all TypeScript compilation errors -3. Run `npm run lint` and fix issues -4. Run `npm run test:run` and fix failing tests -5. Run `npm run build` to verify production build -6. Manual testing of critical flows: - - Profile loading - - Timeline feeds - - Event detail views - - Publishing events - - Account login/logout - ---- - -## Migration Order - -Recommended order to minimize breakage: - -1. **Phase 1.1-1.3**: Package updates and imports (required first) -2. **Phase 3**: Action system migration (isolated change) -3. **Phase 2**: Loader migration (larger change, test carefully) -4. **Phase 4**: Hook migration (use$ is compatible alongside useObservableMemo) -5. **Phase 5**: New features adoption (optional enhancements) -6. **Phase 6**: Documentation updates -7. **Phase 7**: Final testing and verification - ---- - -## Rollback Plan - -If v5 migration encounters blocking issues: -1. Revert package.json to v4 versions -2. Run `npm install` to restore v4 packages -3. Git revert any code changes -4. Document blocking issues for resolution - ---- - -## Estimated Scope - -**Files to modify:** -- `package.json` - 1 file -- Service files (`src/services/`) - 3-4 files -- Hook files (`src/hooks/`) - 5-6 files -- Action files (`src/actions/`) - 3 files -- Helper usage across codebase - 20+ files -- Documentation files - 4-5 files - -**New files:** -- `.claude/skills/applesauce-common/SKILL.md` - -**Risk areas:** -- Loader migration (most complex, affects data loading) -- Helper import paths (many files, easy to miss some) -- Action generator → async function (behavior change) diff --git a/SPELL_SYSTEM_PLAN.md b/SPELL_SYSTEM_PLAN.md deleted file mode 100644 index 4a08117..0000000 --- a/SPELL_SYSTEM_PLAN.md +++ /dev/null @@ -1,501 +0,0 @@ -# Spell System Implementation Plan - -## Executive Summary - -Refactor the spell system to support: -1. **Alias** (local-only quick name) + **Name** (published spell name) -2. Non-technical user-friendly spell creator wizard -3. Spell discovery and browsing UI -4. Improved command palette integration - -## Data Model Changes - -### Current State -```typescript -LocalSpell { - localName?: string; // Local only - description?: string; // Published - command: string; -} - -SpellEvent (kind 777) { - tags: [ - ["cmd", "REQ"], - ["client", "grimoire"], - // NO name tag - ], - content: description -} -``` - -### New State -```typescript -LocalSpell { - alias?: string; // NEW: Local-only quick name - name?: string; // NEW: Mirror from published event - description?: string; - command: string; - isPublished: boolean; - eventId?: string; -} - -SpellEvent (kind 777) { - tags: [ - ["cmd", "REQ"], - ["client", "grimoire"], - ["name", "Bitcoin Feed"], // NEW: Published name - ["t", "bitcoin"], // Topic tags - ], - content: description // Optional -} -``` - -### Key Distinction -- **Alias**: Personal shortcut, typed in command palette (e.g., `btc`) -- **Name**: Public spell title, shown in discovery (e.g., "Bitcoin Feed") -- **Description**: Detailed explanation of what the spell does - -## Implementation Phases - -### Phase 1: Foundation Fixes (Immediate - 2-3 hours) - -**Goal:** Fix data model and current UI - -**Changes:** -1. Add `name` field to `CreateSpellOptions` and `ParsedSpell` types -2. Add name tag encoding/decoding in `spell-conversion.ts` -3. Rename `localName` → `alias` in `LocalSpell` interface -4. Add database migration v9→v10 -5. Update `SpellDialog`: - - Add alias field (local-only, top) - - Add name field (published) - - Rename "Filter" label to "Command" - - Remove Cancel button -6. Update `spell-storage.ts` for alias field -7. Update all tests - -**Files Modified:** -- `src/types/spell.ts` -- `src/lib/spell-conversion.ts` -- `src/services/db.ts` -- `src/services/spell-storage.ts` -- `src/components/nostr/SpellDialog.tsx` -- `src/lib/spell-conversion.test.ts` - -**Success Criteria:** -- ✅ Build passes -- ✅ All tests pass -- ✅ Migration preserves existing spells -- ✅ Can create spells with alias + name -- ✅ Published spells include name tag - ---- - -### Phase 2: Spell Browser (2-3 days) - -**Goal:** Create spell discovery and management UI - -**New Components:** - -1. **SpellsViewer** (`src/components/SpellsViewer.tsx`) - - Main window component (appId: "spells") - - Three tabs: My Spells, Discover, Favorites - - Search bar and filters - - "New Spell" button - -2. **SpellList** (`src/components/nostr/SpellList.tsx`) - - Virtual scrolling for performance - - Sort by: recent, popular, name - - Filter by: content type, author, tags - -3. **SpellCard** (`src/components/nostr/SpellCard.tsx`) - - Compact display with metadata - - Quick actions: Run (▶), Edit (✏), More (⋮) - - Visual distinction: local vs published - -4. **SpellDetailModal** (`src/components/nostr/SpellDetailModal.tsx`) - - Expanded spell view - - Friendly metadata display (no technical REQ syntax) - - Stats: reactions, forks, usage - - Actions: Run, Edit, Fork, Share - -**Features:** -- Browse local and network spells -- Run spells directly from browser -- Fork published spells -- Search by name, alias, description, tags -- Filter by content type (kinds) -- Sort by popularity or recency - -**Command Palette Integration:** -- `spells` → Open spell browser -- `spell create` → Open spell creator -- `` → Run spell by alias -- Autocomplete shows spell suggestions - -**Success Criteria:** -- ✅ Can browse local spells -- ✅ Can discover network spells -- ✅ Can run spells from browser -- ✅ Search and filtering work -- ✅ Command palette integration functional -- ✅ Performance good with 100+ spells - ---- - -### Phase 3: Spell Creator Wizard (3-4 days) - -**Goal:** Non-technical friendly spell creation - -**Wizard Steps:** - -**Step 1: Content Type** -``` -What do you want to see? - -[📝 Notes & Posts] [📰 Long Articles] -[👤 Profiles] [🎨 Images] -[💬 Replies] [🎵 Audio/Video] -[📚 Custom...] -``` - -Visual cards with descriptions, most popular types first. - -**Step 2: Authors** -``` -Who posted this? - -○ Everyone -○ People I follow -○ Specific people: [Search...] -``` - -People picker with: -- Profile pictures and display names -- Search by name, NIP-05, npub -- Multi-select with chips -- Quick "Add from follows" button - -**Step 3: Time Range** -``` -When? - -[⏰ Last Hour] [📅 Today] [🗓️ This Week] -[📆 This Month] [🌐 All Time] - -Or custom: From [___] to [___] -``` - -Visual preset buttons + custom date picker. - -**Step 4: Advanced Filters** (collapsible, optional) -``` -▼ More Options - -Tags: [#bitcoin] [#nostr] [+ Add] -Mentions: [@jack] [+ Add] -Search: [_____________] -Limit: [50 ▼] -``` - -**Step 5: Preview & Name** -``` -Preview - -This spell will show: -📝 Notes from @jack, @alice -⏰ From the last 7 days -🏷 Tagged #bitcoin - -[Live preview of results...] - ---- - -Quick Name (alias): [btc ] -Spell Name (published): [Bitcoin Feed] -Description (published): [___________] - -[< Back] [Save Locally] [Save & Publish] -``` - -**Templates:** -Provide curated templates for quick start: -- My Network (posts from follows) -- Trending Topics (popular recent posts) -- Bitcoin News (#bitcoin #btc) -- Art Gallery (images from artists) - -**Helper Components:** - -1. **PeoplePicker** (`src/components/ui/people-picker.tsx`) - - Author/mention selection - - Profile integration - - Multi-select support - -2. **TagInput** (`src/components/ui/tag-input.tsx`) - - Hashtag selection - - Autocomplete from popular tags - -3. **Wizard Converters** (`src/lib/wizard-converter.ts`) - ```typescript - wizardToCommand(state: WizardState): string - commandToWizard(command: string): WizardState - filterToFriendlyDescription(filter: NostrFilter): string - ``` - -**Success Criteria:** -- ✅ Non-technical users can create spells -- ✅ All wizard steps functional -- ✅ Live preview works -- ✅ Templates load correctly -- ✅ Conversion wizard↔command accurate -- ✅ Keyboard navigation works - ---- - -### Phase 4: Additional Features (Future) - -**Spell Templates** (`src/lib/spell-templates.ts`) -```typescript -interface SpellTemplate { - id: string; - name: string; - description: string; - icon: string; - category: 'social' | 'media' | 'discovery' | 'monitoring'; - requiresAccount: boolean; - wizardDefaults: Partial; -} -``` - -**Spell Discovery Enhancements:** -- Popularity metrics (reactions, forks) -- Trust indicators (verified creators, from follows) -- Categorization by content type -- Network-wide trending spells - -**Command Palette Enhancements:** -- Spell autocomplete with descriptions -- Recent spells quick access -- Fuzzy search for spell names/aliases - -**Future Enhancements (Phase 5):** -- Parameterized spells (variables) -- Scheduled spells (hourly, daily) -- Spell playlists/collections -- Spell analytics and stats -- Collaborative spell sharing -- AI-assisted spell creation - -## Technical Architecture - -### Component Structure -``` -src/ -├── components/ -│ ├── SpellsViewer.tsx # Main spell browser -│ ├── nostr/ -│ │ ├── SpellList.tsx # List of spells -│ │ ├── SpellCard.tsx # Spell card -│ │ ├── SpellDetailModal.tsx # Expanded view -│ │ ├── SpellCreatorWizard.tsx # Wizard main -│ │ ├── SpellEditor.tsx # Rename from SpellDialog -│ │ └── wizard/ -│ │ ├── ContentTypeStep.tsx -│ │ ├── AuthorStep.tsx -│ │ ├── TimeRangeStep.tsx -│ │ ├── AdvancedStep.tsx -│ │ └── PreviewStep.tsx -│ └── ui/ -│ ├── people-picker.tsx -│ └── tag-input.tsx -├── lib/ -│ ├── spell-templates.ts # Curated templates -│ ├── spell-metadata.ts # Filter formatting -│ └── wizard-converter.ts # Wizard ↔ command -├── hooks/ -│ ├── useSpells.ts # Spell data -│ ├── useSpellDiscovery.ts # Network discovery -│ └── useSpellActions.ts # Actions -└── types/ - └── wizard.ts # Wizard state types -``` - -### State Management - -**Option A: Jotai (Current Pattern)** -```typescript -export const localSpellsAtom = atom([]); -export const publishedSpellsAtom = atom([]); -export const spellDiscoveryAtom = atom([]); -``` - -**Option B: React Query (Recommended for Phase 2+)** -```typescript -export function useLocalSpells() { - return useQuery({ - queryKey: ['spells', 'local'], - queryFn: () => getAllSpells(), - }); -} - -export function usePublishedSpells() { - const subscription = useSubscription({ - filter: { kinds: [777] }, - relays: AGGREGATOR_RELAYS, - }); - - return useQuery({ - queryKey: ['spells', 'published'], - queryFn: () => parsePublishedSpells(subscription.events), - }); -} -``` - -### Discovery Mechanisms - -1. **From Follows:** Query kind 777 from contact list -2. **From Aggregators:** Query AGGREGATOR_RELAYS -3. **By Category:** Filter by "k" tags -4. **Search:** Full-text on name, description, tags -5. **Popularity:** Sort by reaction count (kind 7) - -### Performance Considerations - -- Virtual scrolling for spell lists (react-window) -- Debounced search (300ms) -- Lazy load published spells -- Cache parsed spells in memory -- Background sync when inactive - -## Edge Cases & Validation - -### Alias Validation -- Alphanumeric + dash + underscore only: `/^[a-zA-Z0-9_-]+$/` -- Max length: 32 characters -- Cannot conflict with built-in commands (req, profile, etc.) - -### Name Validation -- Any Unicode characters allowed -- Max length: 64 characters -- Optional (can be empty) - -### Description Validation -- Any Unicode characters allowed -- Max length: 500 characters -- Optional (can be empty) - -### Empty Spell Handling -- If no name/alias/description: show "(Unnamed Spell)" -- Auto-derive fallback from command: "Kind 1 Notes" - -### Conflict Resolution -- Alias conflicts: Show warning, allow override -- Published spell updates: Show "Local changes not published" -- Duplicate aliases: Last one wins, show warning - -## Testing Strategy - -### Unit Tests -- Spell encoding/decoding with name tag -- Alias validation -- Filter-to-metadata conversion -- Wizard-to-command conversion -- Database migration - -### Integration Tests -- Create and save spell -- Publish spell to network -- Fork published spell -- Run spell via alias -- Search and filter spells - -### Manual Testing Checklist -- [ ] Create spell from REQ window -- [ ] Create spell via wizard -- [ ] Edit existing spell -- [ ] Delete local spell -- [ ] Publish local spell -- [ ] Fork published spell -- [ ] Run spell via alias -- [ ] Search spells -- [ ] Filter by category -- [ ] Command palette integration - -## Migration Strategy - -### Database Migration v9 → v10 -```typescript -this.version(10) - .stores({ - // ... same schema ... - }) - .upgrade(async (tx) => { - const spells = await tx.table("spells").toArray(); - - for (const spell of spells) { - // Rename localName → alias - if (spell.localName) { - spell.alias = spell.localName; - delete spell.localName; - } - - // Initialize name field - spell.name = spell.name || undefined; - - await tx.table("spells").put(spell); - } - - console.log(`[DB Migration v10] Migrated ${spells.length} spells`); - }); -``` - -**Zero Data Loss:** Existing spells preserved with quick names as aliases. - -## Implementation Timeline - -### Phase 1: Immediate (2-3 hours) -- Foundation fixes -- Data model corrections -- SpellDialog updates -- Tests - -### Phase 2: Spell Browser (2-3 days) -- SpellsViewer component -- Discovery and browsing -- Command palette integration -- Basic actions - -### Phase 3: Wizard (3-4 days) -- Multi-step wizard -- Visual builders -- Templates -- Live preview - -### Total: ~1 week full-time - -## Success Metrics - -- **User Adoption:** 50%+ of users create at least one spell -- **Non-Technical Success:** 30%+ of spells created via wizard -- **Discovery:** 20%+ of runs are discovered spells (not user-created) -- **Performance:** <100ms to load spell browser -- **Quality:** 0 critical bugs in Phase 1 - -## Accessibility - -- Keyboard navigation for all features -- Screen reader support with ARIA labels -- Focus management in modals -- Clear visual hierarchy -- Empty state guidance - -## Conclusion - -This plan transforms the spell system from technical CLI-only to user-friendly with visual builders, while maintaining power-user CLI workflows. The phased approach allows incremental delivery and iteration based on feedback. - -**Next Steps:** -1. Implement Phase 1 (immediate fixes) -2. Test and validate with users -3. Begin Phase 2 (spell browser) -4. Iterate based on feedback diff --git a/docs/req-viewer-improvement-plan.md b/docs/req-viewer-improvement-plan.md deleted file mode 100644 index 44e72ec..0000000 --- a/docs/req-viewer-improvement-plan.md +++ /dev/null @@ -1,1070 +0,0 @@ -# ReqViewer State Machine Improvement Plan - -**Date**: 2025-12-22 -**Goal**: Production-quality REQ status tracking with accurate relay state information - -## Overview - -This plan details the implementation of a robust state machine for ReqViewer that accurately tracks per-relay and overall query status, handles edge cases, and provides production-quality user feedback. - -**See**: `req-viewer-state-analysis.md` for detailed problem analysis. - -## Solution Architecture - -### Hybrid Approach: Connection State + Event Tracking - -We'll combine two sources of truth: -1. **RelayStateManager**: Tracks WebSocket connection state per relay -2. **Event Metadata**: Tracks which relay sent which events (via `_relay` property) - -This hybrid approach avoids duplicate subscriptions while providing accurate status tracking. - -## Implementation Progress - -### COMPLETED: Phase 1: Core Infrastructure -- [x] Task 1.1: Create Per-Relay State Tracking Types (`src/types/req-state.ts`) -- [x] Task 1.2: Create State Derivation Logic (`src/lib/req-state-machine.ts`) -- [x] Task 1.3: Create Enhanced Timeline Hook (`src/hooks/useReqTimelineEnhanced.ts`) -- [x] Unit tests for state machine (`src/lib/req-state-machine.test.ts`) - -### COMPLETED: Phase 2: UI Integration -- [x] Task 2.1: Update ReqViewer Status Indicator with 8-state machine -- [x] Task 2.2: Enhance Relay Dropdown with Per-Relay Status and 2-column grid tooltip -- [x] Task 2.3: Add Empty/Error States (Failed, Offline, Partial) - -### PENDING: Phase 3: Testing & Polish -- [ ] Task 3.1: Add Unit Tests for `useReqTimelineEnhanced` hook -- [ ] Task 3.2: Add Integration Tests for `ReqViewer` UI -- [ ] Task 3.3: Complete Manual Testing Checklist - -### FUTURE: Phase 4: Future Enhancements -- [ ] Task 4.1: Relay Performance Metrics (Latency tracking) -- [ ] Task 4.2: Smart Relay Selection (Integrate with RelayLiveness) -- [ ] Task 4.3: Query Optimization Suggestions - ---- - -## Original Implementation Tasks (Reference) - -#### Task 1.1: Create Per-Relay State Tracking Types - -**File**: `src/types/req-state.ts` (NEW) - -```typescript -/** - * Connection state from RelayStateManager - */ -export type RelayConnectionState = - | 'pending' // Not yet attempted - | 'connecting' // Connection in progress - | 'connected' // WebSocket connected - | 'disconnected' // Disconnected (expected or unexpected) - | 'error'; // Connection error - -/** - * Subscription state specific to this REQ - */ -export type RelaySubscriptionState = - | 'waiting' // Connected but no events yet - | 'receiving' // Events being received - | 'eose' // EOSE received (real or timeout) - | 'error'; // Subscription error - -/** - * Per-relay state for a single REQ subscription - */ -export interface ReqRelayState { - url: string; - - // Connection state (from RelayStateManager) - connectionState: RelayConnectionState; - - // Subscription state (tracked by us) - subscriptionState: RelaySubscriptionState; - - // Event tracking - eventCount: number; - firstEventAt?: number; - lastEventAt?: number; - - // Timing - connectedAt?: number; - eoseAt?: number; - disconnectedAt?: number; - - // Error handling - errorMessage?: string; - errorType?: 'connection' | 'protocol' | 'timeout' | 'auth'; -} - -/** - * Overall query state derived from individual relay states - */ -export type ReqOverallStatus = - | 'discovering' // Selecting relays (NIP-65) - | 'connecting' // Waiting for first relay to connect - | 'loading' // Loading initial events - | 'live' // Streaming after EOSE, relays connected - | 'partial' // Some relays ok, some failed - | 'closed' // All relays completed and closed - | 'failed' // All relays failed - | 'offline'; // All relays disconnected after being live - -/** - * Aggregated state for the entire query - */ -export interface ReqOverallState { - status: ReqOverallStatus; - - // Relay counts - totalRelays: number; - connectedCount: number; - receivingCount: number; - eoseCount: number; - errorCount: number; - disconnectedCount: number; - - // Timing - queryStartedAt: number; - firstEventAt?: number; - allEoseAt?: number; - - // Flags - hasReceivedEvents: boolean; - hasActiveRelays: boolean; - allRelaysFailed: boolean; -} -``` - -**Tests**: `src/types/req-state.test.ts` -- Type checking only, no runtime tests needed - ---- - -#### Task 1.2: Create State Derivation Logic - -**File**: `src/lib/req-state-machine.ts` (NEW) - -```typescript -import type { ReqRelayState, ReqOverallState, ReqOverallStatus } from '@/types/req-state'; - -/** - * Derive overall query status from individual relay states - */ -export function deriveOverallState( - relayStates: Map, - overallEoseReceived: boolean, - isStreaming: boolean, - queryStartedAt: number, -): ReqOverallState { - const states = Array.from(relayStates.values()); - - // Count relay states - const totalRelays = states.length; - const connectedCount = states.filter(s => s.connectionState === 'connected').length; - const receivingCount = states.filter(s => s.subscriptionState === 'receiving').length; - const eoseCount = states.filter(s => s.subscriptionState === 'eose').length; - const errorCount = states.filter(s => s.connectionState === 'error').length; - const disconnectedCount = states.filter(s => s.connectionState === 'disconnected').length; - - // Calculate flags - const hasReceivedEvents = states.some(s => s.eventCount > 0); - const hasActiveRelays = connectedCount > 0; - const allRelaysFailed = totalRelays > 0 && errorCount === totalRelays; - const allDisconnected = totalRelays > 0 && - (disconnectedCount + errorCount) === totalRelays; - - // Timing - const firstEventAt = states - .map(s => s.firstEventAt) - .filter((t): t is number => t !== undefined) - .sort((a, b) => a - b)[0]; - - const allEoseAt = overallEoseReceived ? Date.now() : undefined; - - // Derive status - const status: ReqOverallStatus = (() => { - // No relays selected yet - if (totalRelays === 0) { - return 'discovering'; - } - - // All relays failed to connect - if (allRelaysFailed && !hasReceivedEvents) { - return 'failed'; - } - - // No relays connected, none have sent events - if (!hasActiveRelays && !hasReceivedEvents) { - return 'connecting'; - } - - // Had events, had connections, but all disconnected now - if (allDisconnected && hasReceivedEvents && overallEoseReceived) { - if (isStreaming) { - return 'offline'; // Was live, now offline - } else { - return 'closed'; // Completed and closed - } - } - - // EOSE not received yet, loading initial data - if (!overallEoseReceived) { - return 'loading'; - } - - // EOSE received, streaming mode, relays still connected - if (overallEoseReceived && isStreaming && hasActiveRelays) { - return 'live'; - } - - // EOSE received, but not all relays healthy - if (overallEoseReceived && (errorCount > 0 || disconnectedCount > 0)) { - if (hasActiveRelays) { - return 'partial'; // Some working, some not - } else { - return 'offline'; // All disconnected after EOSE - } - } - - // EOSE received, not streaming, all done - if (overallEoseReceived && !isStreaming) { - return 'closed'; - } - - // Default fallback - return 'loading'; - })(); - - return { - status, - totalRelays, - connectedCount, - receivingCount, - eoseCount, - errorCount, - disconnectedCount, - hasReceivedEvents, - hasActiveRelays, - allRelaysFailed, - queryStartedAt, - firstEventAt, - allEoseAt, - }; -} - -/** - * Get user-friendly status text - */ -export function getStatusText(state: ReqOverallState): string { - switch (state.status) { - case 'discovering': - return 'DISCOVERING RELAYS'; - case 'connecting': - return 'CONNECTING'; - case 'loading': - return state.hasReceivedEvents ? 'LOADING' : 'WAITING'; - case 'live': - return 'LIVE'; - case 'partial': - return `PARTIAL (${state.connectedCount}/${state.totalRelays})`; - case 'offline': - return 'OFFLINE'; - case 'closed': - return 'CLOSED'; - case 'failed': - return 'FAILED'; - } -} - -/** - * Get status indicator color - */ -export function getStatusColor(status: ReqOverallStatus): string { - switch (status) { - case 'discovering': - case 'connecting': - case 'loading': - return 'text-yellow-500'; - case 'live': - case 'partial': - return 'text-green-500'; - case 'closed': - return 'text-muted-foreground'; - case 'offline': - case 'failed': - return 'text-red-500'; - } -} - -/** - * Should status indicator pulse/animate? - */ -export function shouldAnimate(status: ReqOverallStatus): boolean { - return ['discovering', 'connecting', 'loading', 'live'].includes(status); -} -``` - -**Tests**: `src/lib/req-state-machine.test.ts` - -```typescript -import { describe, it, expect } from 'vitest'; -import { deriveOverallState } from './req-state-machine'; -import type { ReqRelayState } from '@/types/req-state'; - -describe('deriveOverallState', () => { - const queryStartedAt = Date.now(); - - describe('discovering state', () => { - it('should return discovering when no relays', () => { - const state = deriveOverallState(new Map(), false, false, queryStartedAt); - expect(state.status).toBe('discovering'); - }); - }); - - describe('connecting state', () => { - it('should return connecting when relays pending', () => { - const relays = new Map([ - ['wss://relay1.com', { - url: 'wss://relay1.com', - connectionState: 'pending', - subscriptionState: 'waiting', - eventCount: 0, - }], - ]); - const state = deriveOverallState(relays, false, false, queryStartedAt); - expect(state.status).toBe('connecting'); - }); - }); - - describe('failed state', () => { - it('should return failed when all relays error with no events', () => { - const relays = new Map([ - ['wss://relay1.com', { - url: 'wss://relay1.com', - connectionState: 'error', - subscriptionState: 'error', - eventCount: 0, - }], - ['wss://relay2.com', { - url: 'wss://relay2.com', - connectionState: 'error', - subscriptionState: 'error', - eventCount: 0, - }], - ]); - const state = deriveOverallState(relays, false, false, queryStartedAt); - expect(state.status).toBe('failed'); - expect(state.allRelaysFailed).toBe(true); - }); - }); - - describe('loading state', () => { - it('should return loading when connected but no EOSE', () => { - const relays = new Map([ - ['wss://relay1.com', { - url: 'wss://relay1.com', - connectionState: 'connected', - subscriptionState: 'receiving', - eventCount: 5, - }], - ]); - const state = deriveOverallState(relays, false, false, queryStartedAt); - expect(state.status).toBe('loading'); - expect(state.hasReceivedEvents).toBe(true); - }); - }); - - describe('live state', () => { - it('should return live when EOSE + streaming + connected', () => { - const relays = new Map([ - ['wss://relay1.com', { - url: 'wss://relay1.com', - connectionState: 'connected', - subscriptionState: 'eose', - eventCount: 10, - }], - ]); - const state = deriveOverallState(relays, true, true, queryStartedAt); - expect(state.status).toBe('live'); - expect(state.hasActiveRelays).toBe(true); - }); - }); - - describe('offline state', () => { - it('should return offline when all disconnected after EOSE in streaming', () => { - const relays = new Map([ - ['wss://relay1.com', { - url: 'wss://relay1.com', - connectionState: 'disconnected', - subscriptionState: 'eose', - eventCount: 10, - }], - ['wss://relay2.com', { - url: 'wss://relay2.com', - connectionState: 'disconnected', - subscriptionState: 'eose', - eventCount: 5, - }], - ]); - const state = deriveOverallState(relays, true, true, queryStartedAt); - expect(state.status).toBe('offline'); - expect(state.hasActiveRelays).toBe(false); - expect(state.hasReceivedEvents).toBe(true); - }); - }); - - describe('partial state', () => { - it('should return partial when some relays ok, some failed', () => { - const relays = new Map([ - ['wss://relay1.com', { - url: 'wss://relay1.com', - connectionState: 'connected', - subscriptionState: 'eose', - eventCount: 10, - }], - ['wss://relay2.com', { - url: 'wss://relay2.com', - connectionState: 'error', - subscriptionState: 'error', - eventCount: 0, - }], - ]); - const state = deriveOverallState(relays, true, true, queryStartedAt); - expect(state.status).toBe('partial'); - expect(state.connectedCount).toBe(1); - expect(state.errorCount).toBe(1); - }); - }); - - describe('closed state', () => { - it('should return closed when EOSE + not streaming', () => { - const relays = new Map([ - ['wss://relay1.com', { - url: 'wss://relay1.com', - connectionState: 'disconnected', - subscriptionState: 'eose', - eventCount: 10, - }], - ]); - const state = deriveOverallState(relays, true, false, queryStartedAt); - expect(state.status).toBe('closed'); - }); - }); -}); -``` - ---- - -#### Task 1.3: Create Enhanced Timeline Hook - -**File**: `src/hooks/useReqTimelineEnhanced.ts` (NEW) - -```typescript -import { useState, useEffect, useMemo, useRef } from "react"; -import pool from "@/services/relay-pool"; -import type { NostrEvent, Filter } from "nostr-tools"; -import { useEventStore } from "applesauce-react/hooks"; -import { isNostrEvent } from "@/lib/type-guards"; -import { useStableValue, useStableArray } from "./useStable"; -import { useRelayState } from "./useRelayState"; -import type { ReqRelayState, ReqOverallState } from "@/types/req-state"; -import { deriveOverallState } from "@/lib/req-state-machine"; - -interface UseReqTimelineEnhancedOptions { - limit?: number; - stream?: boolean; -} - -interface UseReqTimelineEnhancedReturn { - events: NostrEvent[]; - loading: boolean; - error: Error | null; - eoseReceived: boolean; - - // Enhanced state tracking - relayStates: Map; - overallState: ReqOverallState; -} - -/** - * Enhanced REQ timeline hook with per-relay state tracking - * - * Combines: - * - Group subscription for events (with deduplication) - * - RelayStateManager for connection state - * - Event metadata for relay-specific tracking - * - * @param id - Unique identifier for this timeline - * @param filters - Nostr filter(s) - * @param relays - Array of relay URLs - * @param options - Stream mode, limit, etc. - */ -export function useReqTimelineEnhanced( - id: string, - filters: Filter | Filter[], - relays: string[], - options: UseReqTimelineEnhancedOptions = { limit: 50 } -): UseReqTimelineEnhancedReturn { - const eventStore = useEventStore(); - const { limit, stream = false } = options; - - // Existing state from useReqTimeline - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [eoseReceived, setEoseReceived] = useState(false); - const [eventsMap, setEventsMap] = useState>(new Map()); - - // New: Per-relay state tracking - const [relayStates, setRelayStates] = useState>(new Map()); - const queryStartedAt = useRef(Date.now()); - - // Get global relay connection states - const { relays: globalRelayStates } = useRelayState(); - - // Sort events by created_at - const events = useMemo(() => { - return Array.from(eventsMap.values()).sort( - (a, b) => b.created_at - a.created_at - ); - }, [eventsMap]); - - // Stabilize inputs - const stableFilters = useStableValue(filters); - const stableRelays = useStableArray(relays); - - // Initialize relay states when relays change - useEffect(() => { - queryStartedAt.current = Date.now(); - - const initialStates = new Map(); - for (const url of relays) { - initialStates.set(url, { - url, - connectionState: 'pending', - subscriptionState: 'waiting', - eventCount: 0, - }); - } - setRelayStates(initialStates); - }, [stableRelays]); - - // Sync connection states from RelayStateManager - useEffect(() => { - setRelayStates(prev => { - const next = new Map(prev); - let changed = false; - - for (const [url, state] of prev) { - const globalState = globalRelayStates[url]; - if (globalState && globalState.connectionState !== state.connectionState) { - next.set(url, { - ...state, - connectionState: globalState.connectionState as any, - connectedAt: globalState.lastConnected, - disconnectedAt: globalState.lastDisconnected, - }); - changed = true; - } - } - - return changed ? next : prev; - }); - }, [globalRelayStates]); - - // Subscribe to events - useEffect(() => { - if (relays.length === 0) { - setLoading(false); - return; - } - - setLoading(true); - setError(null); - setEoseReceived(false); - setEventsMap(new Map()); - - // Normalize filters - const filterArray = Array.isArray(filters) ? filters : [filters]; - const filtersWithLimit = filterArray.map(f => ({ - ...f, - limit: limit || f.limit, - })); - - const observable = pool.subscription(relays, filtersWithLimit, { - retries: 5, - reconnect: 5, - resubscribe: true, - eventStore, - }); - - const subscription = observable.subscribe( - (response) => { - if (typeof response === "string") { - // EOSE received - setEoseReceived(true); - if (!stream) { - setLoading(false); - } - - // Mark all connected relays as having received EOSE - // Note: We can't tell which relay sent EOSE due to applesauce bug - // So we mark all connected ones - setRelayStates(prev => { - const next = new Map(prev); - for (const [url, state] of prev) { - if (state.connectionState === 'connected') { - next.set(url, { - ...state, - subscriptionState: 'eose', - eoseAt: Date.now(), - }); - } - } - return next; - }); - } else if (isNostrEvent(response)) { - // Event received - const event = response as NostrEvent & { _relay?: string }; - const relayUrl = event._relay; - - // Store event - eventStore.add(event); - setEventsMap(prev => { - const next = new Map(prev); - next.set(event.id, event); - return next; - }); - - // Update relay state - if (relayUrl) { - setRelayStates(prev => { - const state = prev.get(relayUrl); - if (!state) return prev; - - const now = Date.now(); - const next = new Map(prev); - next.set(relayUrl, { - ...state, - subscriptionState: 'receiving', - eventCount: state.eventCount + 1, - firstEventAt: state.firstEventAt ?? now, - lastEventAt: now, - }); - return next; - }); - } - } - }, - (err: Error) => { - console.error("REQ: Error", err); - setError(err); - setLoading(false); - }, - () => { - if (!stream) { - setLoading(false); - } - } - ); - - return () => { - subscription.unsubscribe(); - }; - }, [id, stableFilters, stableRelays, limit, stream, eventStore]); - - // Derive overall state - const overallState = useMemo(() => { - return deriveOverallState( - relayStates, - eoseReceived, - stream, - queryStartedAt.current - ); - }, [relayStates, eoseReceived, stream]); - - return { - events, - loading, - error, - eoseReceived, - relayStates, - overallState, - }; -} -``` - -**Tests**: `src/hooks/useReqTimelineEnhanced.test.ts` -- Mock pool.subscription -- Test state transitions -- Test relay state tracking -- Test overall state derivation - ---- - -### Phase 2: UI Integration - -#### Task 2.1: Update ReqViewer Status Indicator - -**File**: `src/components/ReqViewer.tsx` - -**Changes**: -1. Import enhanced hook and state machine helpers -2. Replace `useReqTimeline` with `useReqTimelineEnhanced` -3. Update status indicator (lines 916-957) to use `overallState.status` -4. Update connection count to show connected vs total - -```typescript -// Before -const { events, loading, error, eoseReceived } = useReqTimeline( - `req-${JSON.stringify(filter)}-${closeOnEose}`, - resolvedFilter, - finalRelays, - { limit: resolvedFilter.limit || 50, stream } -); - -// After -const { events, loading, error, eoseReceived, relayStates, overallState } = - useReqTimelineEnhanced( - `req-${JSON.stringify(filter)}-${closeOnEose}`, - resolvedFilter, - finalRelays, - { limit: resolvedFilter.limit || 50, stream } - ); - -// Status indicator - - - {getStatusText(overallState)} - - -// Connection count - - {overallState.connectedCount}/{overallState.totalRelays} - -``` - ---- - -#### Task 2.2: Enhance Relay Dropdown with Per-Relay Status - -**File**: `src/components/ReqViewer.tsx` - -**Changes**: Update relay dropdown (lines 998-1050) to show per-relay subscription state - -```typescript - - {/* Connection Status */} -
-
- Relay Status -
- {Array.from(relayStates.values()).map((relayState) => { - const globalState = relayStates[relayState.url]; - const connIcon = getConnectionIcon(globalState); - - return ( - - - - {/* Event count */} -
- {relayState.eventCount > 0 && ( - - -
- - {relayState.eventCount} -
-
- - {relayState.eventCount} events received - -
- )} - - {/* Subscription state badge */} - {relayState.subscriptionState === 'receiving' && ( - RECEIVING - )} - {relayState.subscriptionState === 'eose' && ( - EOSE - )} - {relayState.subscriptionState === 'error' && ( - ERROR - )} - - {/* Connection icon */} - - -
{connIcon.icon}
-
- -

{connIcon.label}

-
-
-
-
- ); - })} -
- - {/* Relay Selection (NIP-65) */} - {/* ... existing code ... */} -
-``` - ---- - -#### Task 2.3: Add Empty/Error States - -**File**: `src/components/ReqViewer.tsx` - -**Changes**: Add specific UI for failed/offline states - -```typescript -{/* All Relays Failed */} -{overallState.status === 'failed' && ( -
-
- -

All Relays Failed

-

- Could not connect to any of the {overallState.totalRelays} relays. - Check your network connection or try different relays. -

-
-
-)} - -{/* All Relays Offline (after being live) */} -{overallState.status === 'offline' && overallState.hasReceivedEvents && ( -
- - ⚠️ All relays disconnected. Showing cached results. - -
-)} - -{/* Partial Connection Warning */} -{overallState.status === 'partial' && ( -
- - ⚠️ Only {overallState.connectedCount}/{overallState.totalRelays} relays connected - -
-)} -``` - ---- - -### Phase 3: Testing & Polish - -#### Task 3.1: Add Unit Tests - -**Files**: -- `src/lib/req-state-machine.test.ts` (already outlined above) -- `src/hooks/useReqTimelineEnhanced.test.ts` - -**Test Coverage**: -- All state transitions -- Edge cases from analysis document -- Event tracking -- Connection state synchronization - ---- - -#### Task 3.2: Add Integration Tests - -**File**: `src/components/ReqViewer.test.tsx` (NEW) - -**Scenarios**: -1. All relays offline → shows "FAILED" -2. Mixed success/failure → shows "PARTIAL" -3. Streaming with disconnections → shows "OFFLINE" -4. Single relay timeout → appropriate status - ---- - -#### Task 3.3: Manual Testing Checklist - -**File**: `docs/req-viewer-test-scenarios.md` (NEW) - -Create manual test scenarios: -- [ ] Query with 30 relays, all offline -- [ ] Query with 10 relays, 5 succeed, 5 fail -- [ ] Query with 1 relay that times out (>10s) -- [ ] Streaming query, disconnect relays one by one -- [ ] Streaming query, all relays disconnect -- [ ] Non-streaming query, normal completion -- [ ] Query with AUTH-required relay -- [ ] Query with slow relay (8-12s response) -- [ ] Query with mix of fast/slow/failed relays - ---- - -### Phase 4: Future Enhancements - -#### Task 4.1: Relay Performance Metrics - -Track and display: -- Average response time per relay -- Success/failure rate -- Event count distribution -- EOSE latency - -#### Task 4.2: Smart Relay Selection - -Integrate with RelayLiveness: -- Skip relays in backoff state -- Prefer historically fast relays -- Warn about consistently failing relays - -#### Task 4.3: Query Optimization Suggestions - -Analyze query and suggest: -- "Query too broad, consider adding time range" -- "Consider using NIP-65 outbox relays" -- "Relay X frequently fails, consider removing" - ---- - -## Implementation Schedule - -### Week 1: Core Infrastructure -- Day 1-2: Tasks 1.1, 1.2 (types + state machine) -- Day 3-4: Task 1.3 (enhanced hook) -- Day 5: Unit tests (Task 3.1) - -### Week 2: UI Integration -- Day 1-2: Task 2.1 (status indicator) -- Day 3: Task 2.2 (relay dropdown) -- Day 4: Task 2.3 (empty states) -- Day 5: Integration tests (Task 3.2) - -### Week 3: Testing & Polish -- Day 1-2: Manual testing (Task 3.3) -- Day 3-4: Bug fixes and refinements -- Day 5: Documentation and code review - ---- - -## Success Criteria - -### Must Have (Phase 1-2) -- [x] "LIVE" only shows when relays actually connected -- [x] Distinguish between CLOSED, OFFLINE, and FAILED states -- [x] Show accurate connected relay count -- [x] Per-relay status in dropdown -- [x] Handle "all relays disconnected" case correctly - -### Should Have (Phase 3) -- [ ] Unit tests covering all state transitions -- [ ] Integration tests for key scenarios -- [ ] Manual test scenarios documented and passing - -### Nice to Have (Phase 4) -- [ ] Relay performance metrics -- [ ] Smart relay selection based on history -- [ ] Query optimization suggestions - ---- - -## Risks & Mitigation - -### Risk 1: Can't distinguish real EOSE from timeout/error -**Impact**: Medium -**Mitigation**: Track connection state + events received to infer state - -### Risk 2: Event metadata might not have `_relay` property -**Impact**: High -**Mitigation**: Verify `markFromRelay()` operator is working, fallback to all-connected logic - -### Risk 3: State synchronization lag between hooks -**Impact**: Low -**Mitigation**: Use stable references, debounce updates if needed - -### Risk 4: Performance impact of per-relay tracking -**Impact**: Low -**Mitigation**: Use Map for O(1) lookups, memoize derived state - ---- - -## Rollout Plan - -### Phase 1: Soft Launch -1. Merge behind feature flag -2. Test internally with various queries -3. Gather feedback from team - -### Phase 2: Beta -1. Enable for subset of users -2. Monitor for issues -3. Collect user feedback - -### Phase 3: General Availability -1. Enable for all users -2. Document new status indicators -3. Create help articles - ---- - -## Documentation Updates - -### User-Facing -- Update help docs with new status indicators -- Explain what each status means -- Add troubleshooting guide for failed queries - -### Developer-Facing -- Document ReqRelayState and ReqOverallState types -- Document state machine transitions -- Add ADR (Architecture Decision Record) - ---- - -## Related Work - -### Upstream Issues -- Submit PR to applesauce-relay for catchError bug -- Propose per-relay EOSE tracking API enhancement - -### Technical Debt -- Migrate other timeline hooks to enhanced version -- Consolidate timeline state management -- Improve relay health tracking - ---- - -## Monitoring & Metrics - -### Success Metrics -- Reduction in user-reported "LIVE with 0 relays" issues -- Improved query success rate (user perception) -- Reduced confusion about query status - -### Technical Metrics -- State machine transition frequency -- Per-relay success/failure rates -- Average query completion time -- EOSE latency distribution - ---- - -## References - -- Analysis: `docs/req-viewer-state-analysis.md` -- NIP-01: https://github.com/nostr-protocol/nips/blob/master/01.md -- Applesauce-relay: node_modules/applesauce-relay/dist/ -- RelayStateManager: `src/services/relay-state-manager.ts` diff --git a/docs/req-viewer-state-analysis.md b/docs/req-viewer-state-analysis.md deleted file mode 100644 index fc7e5da..0000000 --- a/docs/req-viewer-state-analysis.md +++ /dev/null @@ -1,787 +0,0 @@ -# ReqViewer State Machine Analysis - -**Date**: 2025-12-22 -**Issue**: Disconnected relays are incorrectly shown as "LIVE" and counted as having sent EOSE - -## Executive Summary - -The ReqViewer state machine has a critical bug where relay disconnections are indistinguishable from EOSE messages, leading to incorrect status indicators. A query using 30 relays where all disconnect will show "LIVE" status with 0/30 relays connected. - -## Architecture Overview - -### Current Flow - -``` -User Query → useReqTimeline → pool.subscription → RelayGroup → Individual Relays - ↓ ↓ - setEoseReceived(true) ←── "EOSE" string ←── catchError → DISCONNECTION - ↓ - Shows "LIVE" indicator -``` - -### Key Components - -1. **ReqViewer** (`src/components/ReqViewer.tsx`): - - UI component that displays query results and status - - Lines 918-957: Status indicator logic based on `loading`, `eoseReceived`, `stream` - - Lines 735-737: Connected relay count based on `connectionState === "connected"` - -2. **useReqTimeline** (`src/hooks/useReqTimeline.ts`): - - Hook that manages REQ subscription - - Line 88: Sets `eoseReceived = true` when response is string "EOSE" - - No awareness of relay disconnection state - -3. **RelayPool** (applesauce-relay): - - `pool.subscription()` delegates to RelayGroup - - Uses retry/reconnect logic but doesn't expose per-relay EOSE state - -4. **RelayGroup** (applesauce-relay/dist/group.js): - - **CRITICAL BUG HERE**: Line with `catchError(() => of("EOSE"))` - - Treats ANY error (including disconnection) as EOSE - - Aggregates EOSE from all relays before emitting overall EOSE - -5. **Relay** (applesauce-relay/dist/relay.js): - - Individual relay connection - - Has 10-second EOSE timeout that emits fake EOSE if none received - - Emits observables: `connected$`, `challenge$`, `authenticated$`, `notice$` - -## Critical Bug: Error Handling in RelayGroup - -### The Problem - -In `node_modules/applesauce-relay/dist/group.js`: - -```javascript -const observable = project(relay).pipe( - // Catch connection errors and return EOSE - catchError(() => of("EOSE")), // ← BUG: Disconnections become EOSE! - map((value) => [relay, value]) -); -``` - -**Why this is problematic**: -- A relay that never connected emits "EOSE" -- A relay that disconnects mid-query emits "EOSE" -- A relay with a WebSocket error emits "EOSE" -- These fake EOSE messages are indistinguishable from real ones - -### EOSE Aggregation Logic - -```javascript -const eose = this.relays$.pipe( - switchMap((relays) => - main.pipe( - filter(([_, value]) => value === "EOSE"), - scan((received, [relay]) => [...received, relay], []), - // Wait until ALL relays have "sent" EOSE - takeWhile((received) => relays.some((r) => !received.includes(r))), - ignoreElements(), - endWith("EOSE") // ← Emits when all relays done (or errored) - ) - ) -); -``` - -**Result**: The overall EOSE is emitted when: -- ✅ All relays sent real EOSE and are streaming -- ✅ All relays sent real EOSE and closed connection -- ❌ All relays disconnected (caught and turned into fake EOSE) -- ❌ Mix of real EOSE and disconnections (can't tell the difference) - -## Edge Cases & Failure Scenarios - -### Scenario 1: All Relays Disconnect Immediately -**Setup**: Query with 10 relays, all are offline or reject connection -**Current Behavior**: -- Each relay: `catchError` → emits "EOSE" -- useReqTimeline: Sets `eoseReceived = true` -- ReqViewer: Shows "LIVE" indicator (green, pulsing) -- Connection count: 0/10 -- User sees: "LIVE" with 0 connected relays - -**Expected Behavior**: Show "ERROR" or "NO RELAYS" status - -### Scenario 2: Slow Relays with Timeout -**Setup**: Query with relay that takes 15 seconds to respond -**Current Behavior**: -- After 10s: EOSE timeout fires → emits fake "EOSE" -- Relay still connected, might send more events later -- User sees: "LIVE" but relay is counted as "done" - -**Expected Behavior**: Continue waiting or show "PARTIAL" status - -### Scenario 3: Mixed Success/Failure -**Setup**: 30 relays, 10 succeed with EOSE, 15 disconnect, 5 timeout -**Current Behavior**: -- All 30 eventually emit "EOSE" (real or fake) -- Overall EOSE emitted -- Shows "LIVE" with 10/30 connected -- User can't tell which relays actually completed vs failed - -**Expected Behavior**: Show per-relay status and overall "PARTIAL" indicator - -### Scenario 4: Mid-Query Disconnection -**Setup**: Relay sends 50 events, then disconnects before EOSE -**Current Behavior**: -- Disconnection → `catchError` → fake "EOSE" -- Events are shown, looks like query completed successfully -- No indication that query was interrupted - -**Expected Behavior**: Show warning that relay disconnected mid-query - -### Scenario 5: Streaming Mode with Gradual Disconnections -**Setup**: Query in streaming mode, relays disconnect one by one -**Current Behavior**: -- Each disconnection → fake "EOSE" -- Eventually all relays have "EOSE" -- Shows "LIVE" with 0/30 connected (THE REPORTED BUG!) - -**Expected Behavior**: Show "OFFLINE" or "NO ACTIVE RELAYS" when all disconnect - -### Scenario 6: Single Relay Query -**Setup**: Query with explicit relay that doesn't respond -**Current Behavior**: -- After 10s timeout → fake "EOSE" -- Shows "CLOSED" (not streaming) -- No indication relay never responded - -**Expected Behavior**: Show "TIMEOUT" or "NO RESPONSE" status - -### Scenario 7: AUTH Required But Not Provided -**Setup**: Relay requires authentication, no account active -**Current Behavior**: -- Relay returns "auth-required" CLOSED message -- Caught and turned into "EOSE" -- Looks like query completed with no results - -**Expected Behavior**: Show "AUTH REQUIRED" status - -## State Machine Requirements - -### Required States - -**Query-Level States**: -- `DISCOVERING`: Selecting relays (NIP-65 outbox discovery) -- `CONNECTING`: Waiting for first relay to connect -- `LOADING`: At least one relay connected, waiting for initial EOSE -- `LIVE`: At least one relay streaming after EOSE -- `PARTIAL`: Some relays completed, some failed/disconnected -- `CLOSED`: All relays sent EOSE and closed (non-streaming) -- `FAILED`: All relays failed to connect or errored -- `TIMEOUT`: No relays responded within timeout -- `AUTH_REQUIRED`: Some/all relays require authentication - -**Per-Relay States** (tracked separately): -- `PENDING`: Relay in list but not yet connected -- `CONNECTING`: Connection attempt in progress -- `CONNECTED`: WebSocket open, REQ sent -- `RECEIVING`: Events being received -- `EOSE_RECEIVED`: EOSE message received (still connected) -- `CLOSED`: Clean closure after EOSE -- `DISCONNECTED`: Unexpected disconnection -- `ERROR`: Connection error or protocol error -- `TIMEOUT`: No response within timeout -- `AUTH_REQUIRED`: Relay requires authentication - -### State Transition Rules - -**Query Level**: -``` -DISCOVERING → CONNECTING (when relays selected) -CONNECTING → LOADING (when first relay connects) -CONNECTING → FAILED (when all relay connections fail, timeout) - -LOADING → LIVE (when EOSE received, stream=true, >0 relays connected) -LOADING → PARTIAL (when some EOSE, some failed, stream=true) -LOADING → CLOSED (when all EOSE received, stream=false) -LOADING → FAILED (when all relays fail before EOSE) - -LIVE → PARTIAL (when some relays disconnect) -LIVE → FAILED (when all relays disconnect) - -PARTIAL → LIVE (when previously failed relays reconnect) -PARTIAL → FAILED (when remaining relays disconnect) -``` - -**Per-Relay** (tracked in RelayStateManager): -``` -PENDING → CONNECTING (when connection initiated) -CONNECTING → CONNECTED (when WebSocket open, REQ sent) -CONNECTING → ERROR (when connection fails) -CONNECTING → TIMEOUT (when connection takes too long) - -CONNECTED → RECEIVING (when first event received) -CONNECTED → EOSE_RECEIVED (when EOSE received, no prior events) -CONNECTED → ERROR (when connection lost) - -RECEIVING → EOSE_RECEIVED (when EOSE received) -RECEIVING → DISCONNECTED (when connection lost before EOSE) -RECEIVING → ERROR (when protocol error) - -EOSE_RECEIVED → CLOSED (when relay closes connection after EOSE) -EOSE_RECEIVED → DISCONNECTED (when relay keeps connection open in streaming) -``` - -## Data Requirements - -### Information We Need But Don't Have - -1. **Per-Relay EOSE Status**: - - Which relays sent real EOSE? - - Which relays disconnected without EOSE? - - Which relays timed out? - - Which relays are still streaming? - -2. **Per-Relay Event Counts**: - - How many events did each relay send? - - Useful for showing progress and diagnosing issues - -3. **Error Details**: - - Why did relay fail? (connection refused, timeout, protocol error, auth required) - - Currently lost in `catchError(() => of("EOSE"))` - -4. **Timing Information**: - - When did relay connect? - - When did first event arrive? - - When did EOSE arrive? - - How long did query take per relay? - -5. **Relay Health Context**: - - Is relay in RelayLiveness backoff state? - - Has relay been failing consistently? - - Should we even attempt connection? - -### Information We Have But Don't Use - -From **RelayStateManager** (`src/services/relay-state-manager.ts`): -- ✅ `connectionState`: "connected" | "connecting" | "disconnected" | "error" -- ✅ `lastConnected`, `lastDisconnected`: Timestamps -- ✅ `errors[]`: Array of error messages with types -- ✅ `stats.connectionsCount`: How many times relay connected - -From **RelayLiveness** (`src/services/relay-liveness.ts`): -- ✅ Failure counts per relay -- ✅ Backoff states -- ✅ Last success/failure times -- ✅ Should prevent connection attempts to dead relays - -**Problem**: useReqTimeline doesn't integrate with either of these! - -## Nostr Protocol Semantics - -### REQ Lifecycle (NIP-01) - -1. Client sends: `["REQ", , , , ...]` -2. Relay responds with zero or more: `["EVENT", , ]` -3. Relay sends: `["EOSE", ]` when initial query complete -4. Client can keep subscription open for streaming -5. Client closes: `["CLOSE", ]` -6. Relay can close: `["CLOSED", , ]` - -### EOSE Semantics - -**What EOSE means**: -- ✅ "I have sent all stored events matching your filter" -- ✅ "Initial query phase is complete" -- ✅ Connection is still open (unless relay closes immediately after) - -**What EOSE does NOT mean**: -- ❌ "No more events will be sent" (streaming continues) -- ❌ "Connection is closing" -- ❌ "Query was successful" (could have returned 0 events) - -### CLOSED Semantics - -**Why relays send CLOSED**: -- `auth-required`: AUTH event required before query -- `rate-limited`: Too many requests -- `error`: Generic error (parsing, internal, etc.) -- `invalid`: Filter validation failed - -**Client should**: -- Distinguish CLOSED from EOSE -- Handle auth-required by prompting user -- Handle rate-limiting with backoff -- Show errors to user - -## Applesauce Behavior Analysis - -### Retry/Reconnect Logic - -**relay.subscription()** options: -- `retries` (deprecated): Number of retry attempts -- `reconnect` (default: true, 10 retries): Retry on connection failures -- `resubscribe` (default: false): Resubscribe if relay sends CLOSED - -**Current usage in useReqTimeline.ts**: -```typescript -pool.subscription(relays, filtersWithLimit, { - retries: 5, - reconnect: 5, - resubscribe: true, - eventStore, -}); -``` - -**Behavior**: -- Retries connection failures up to 5 times -- Resubscribes if relay sends CLOSED (like auth-required) -- Uses exponential backoff (see `Relay.createReconnectTimer`) - -**Issue**: All this retry logic happens inside applesauce, invisible to useReqTimeline. We can't show "RETRYING" status or retry count to user. - -### Group Subscription Behavior - -**relay.subscription()** in RelayGroup: -```javascript -subscription(filters, opts) { - return this.internalSubscription( - (relay) => relay.subscription(filters, opts), - opts?.eventStore == null ? identity : filterDuplicateEvents(opts?.eventStore) - ); -} -``` - -**Key behaviors**: -1. Creates observable for each relay -2. Merges all observables -3. Deduplicates events via EventStore -4. Catches errors and converts to "EOSE" (THE BUG) -5. Emits overall "EOSE" when all relays done - -**Missing**: -- No per-relay state tracking -- No way to query "which relays have sent EOSE?" -- No way to query "which relays are still connected?" -- Error information is lost - -## Technical Constraints - -### What We Can't Change - -1. **Applesauce-relay library behavior**: - - We can't modify the `catchError(() => of("EOSE"))` in RelayGroup - - This is in node_modules, upstream library - - Would need to fork or submit PR - -2. **Observable-based API**: - - pool.subscription returns `Observable` - - Response is either `NostrEvent` or string `"EOSE"` - - Can't change this interface without forking - -3. **Relay connection pooling**: - - RelayPool manages all relay connections globally - - Multiple components can share same relay connection - - Can't have per-query relay isolation - -### What We Can Work With - -1. **RelayStateManager**: - - Already tracks per-relay connection state - - Updates in real-time via observables - - Available via `useRelayState()` hook - - CAN BE ENHANCED to track per-query state - -2. **EventStore**: - - Already receives all events - - Could be instrumented to track per-relay events - - Has access to relay URL via event metadata - -3. **Custom observables**: - - We can tap into the subscription observable - - Track events and EOSE per relay ourselves - - Build parallel state tracking - -4. **Relay URL in events**: - - Events marked with relay URL via `markFromRelay()` operator - - Can track which relay sent which events - -## Proposed Solutions - -### Solution 1: Per-Relay Subscription Tracking (Recommended) - -**Approach**: Track individual relay subscriptions in parallel with the group subscription. - -**Implementation**: -```typescript -interface RelaySubscriptionState { - url: string; - status: 'pending' | 'connecting' | 'receiving' | 'eose' | 'closed' | 'error'; - eventCount: number; - firstEventAt?: number; - eoseAt?: number; - error?: Error; -} - -function useReqTimelineEnhanced(id, filters, relays, options) { - const [relayStates, setRelayStates] = useState>(); - - // Subscribe to individual relays - useEffect(() => { - const subs = relays.map(url => { - const relay = pool.relay(url); - return relay.req(filters).subscribe({ - next: (response) => { - if (response === 'EOSE') { - setRelayStates(prev => prev.set(url, { ...prev.get(url), status: 'eose', eoseAt: Date.now() })); - } else { - setRelayStates(prev => prev.set(url, { - ...prev.get(url), - status: 'receiving', - eventCount: (prev.get(url)?.eventCount ?? 0) + 1 - })); - } - }, - error: (err) => { - setRelayStates(prev => prev.set(url, { ...prev.get(url), status: 'error', error: err })); - } - }); - }); - - return () => subs.forEach(sub => sub.unsubscribe()); - }, [relays, filters]); - - // Derive overall state from individual relay states - const overallState = useMemo(() => { - const states = Array.from(relayStates.values()); - const connected = states.filter(s => ['receiving', 'eose'].includes(s.status)); - const eose = states.filter(s => s.status === 'eose'); - const errors = states.filter(s => s.status === 'error'); - - if (connected.length === 0 && errors.length === states.length) return 'FAILED'; - if (eose.length === states.length) return 'CLOSED'; - if (eose.length > 0 && connected.length > 0) return 'LIVE'; - if (connected.length > 0) return 'LOADING'; - return 'CONNECTING'; - }, [relayStates]); - - return { events, relayStates, overallState }; -} -``` - -**Pros**: -- ✅ Accurate per-relay tracking -- ✅ Can distinguish real EOSE from errors -- ✅ Works around applesauce bug without forking -- ✅ Provides rich debugging information - -**Cons**: -- ❌ Duplicate subscriptions (one per relay + one group) -- ❌ More memory usage -- ❌ Potential for state synchronization issues - -### Solution 2: Enhanced Group Observable Wrapper - -**Approach**: Wrap the group subscription and parse relay URL from event metadata. - -**Implementation**: -```typescript -function useReqTimelineWithTracking(id, filters, relays, options) { - const [relayEose, setRelayEose] = useState>(new Set()); - const { relays: relayStates } = useRelayState(); - - useEffect(() => { - const observable = pool.subscription(relays, filters, options).pipe( - tap(response => { - if (typeof response === 'string' && response === 'EOSE') { - // This is the aggregated EOSE, check which relays are still connected - const stillConnected = relays.filter(url => - relayStates[url]?.connectionState === 'connected' - ); - // If no relays connected, treat as failure not EOSE - if (stillConnected.length === 0) { - setError(new Error('All relays disconnected')); - return; - } - } else if (isNostrEvent(response)) { - // Track which relay sent this event - const relayUrl = (response as any)._relay; // Added by markFromRelay() - if (relayUrl && !relayEose.has(relayUrl)) { - // Mark relay as active/receiving - } - } - }) - ); - - return observable.subscribe(/* ... */); - }, [relays, filters]); -} -``` - -**Pros**: -- ✅ Single subscription (no duplication) -- ✅ Uses existing infrastructure -- ✅ Leverages RelayStateManager - -**Cons**: -- ❌ Can't distinguish real EOSE from fake (happens in applesauce) -- ❌ Relies on relay URL being added to events -- ❌ Still shows "EOSE" when all relays disconnect - -### Solution 3: Fork Applesauce-Relay (Not Recommended) - -**Approach**: Fork applesauce-relay and fix the catchError bug. - -**Changes needed**: -```typescript -// In group.js, change: -catchError(() => of("EOSE")) - -// To: -catchError((err) => of({ type: 'ERROR', relay, error: err })) - -// And update EOSE aggregation to only count real EOSE -``` - -**Pros**: -- ✅ Fixes root cause -- ✅ Proper error handling -- ✅ Could be upstreamed - -**Cons**: -- ❌ Maintenance burden of fork -- ❌ Need to track upstream changes -- ❌ Breaks applesauce API contract - -### Solution 4: Hybrid Approach (RECOMMENDED) - -**Combine** Solution 1 + Solution 2: -1. Use RelayStateManager to track connection state -2. Subscribe to group observable for events (deduplication) -3. Build per-relay state machine based on: - - Connection state from RelayStateManager - - Events received (tracked by relay URL in metadata) - - Overall EOSE from group subscription -4. Derive accurate overall state - -**Implementation** in new file `src/hooks/useReqTimelineEnhanced.ts`: -```typescript -interface ReqRelayState { - url: string; - connectionState: 'pending' | 'connecting' | 'connected' | 'disconnected' | 'error'; - subscriptionState: 'waiting' | 'receiving' | 'eose' | 'timeout' | 'error'; - eventCount: number; - firstEventAt?: number; - lastEventAt?: number; - errorMessage?: string; -} - -interface ReqOverallState { - status: 'discovering' | 'connecting' | 'loading' | 'live' | 'partial' | 'closed' | 'failed'; - connectedCount: number; - eoseCount: number; - errorCount: number; - totalRelays: number; -} - -export function useReqTimelineEnhanced( - id: string, - filters: Filter | Filter[], - relays: string[], - options: UseReqTimelineOptions = {} -) { - // State - const [relayStates, setRelayStates] = useState>(new Map()); - const [overallEose, setOverallEose] = useState(false); - - // Get relay connection states - const { relays: globalRelayStates } = useRelayState(); - - // Subscribe to events - const observable = pool.subscription(relays, filters, options); - - useEffect(() => { - // Initialize relay states - setRelayStates(new Map(relays.map(url => [ - url, - { - url, - connectionState: 'pending', - subscriptionState: 'waiting', - eventCount: 0, - } - ]))); - - const sub = observable.subscribe({ - next: (response) => { - if (response === 'EOSE') { - setOverallEose(true); - } else { - const event = response as NostrEvent; - const relayUrl = (event as any)._relay; - - setRelayStates(prev => { - const state = prev.get(relayUrl); - if (!state) return prev; - - const next = new Map(prev); - next.set(relayUrl, { - ...state, - subscriptionState: 'receiving', - eventCount: state.eventCount + 1, - firstEventAt: state.firstEventAt ?? Date.now(), - lastEventAt: Date.now(), - }); - return next; - }); - } - }, - error: (err) => { - // Overall subscription error - }, - }); - - return () => sub.unsubscribe(); - }, [relays, filters]); - - // Sync connection state from RelayStateManager - useEffect(() => { - setRelayStates(prev => { - const next = new Map(prev); - for (const [url, state] of prev) { - const globalState = globalRelayStates[url]; - if (globalState) { - next.set(url, { - ...state, - connectionState: globalState.connectionState as any, - }); - } - } - return next; - }); - }, [globalRelayStates]); - - // Derive overall state - const overallState: ReqOverallState = useMemo(() => { - const states = Array.from(relayStates.values()); - const connected = states.filter(s => s.connectionState === 'connected'); - const receivedData = states.filter(s => s.eventCount > 0); - const errors = states.filter(s => s.connectionState === 'error'); - - const status = (() => { - if (relays.length === 0) return 'discovering'; - if (connected.length === 0 && errors.length === states.length) return 'failed'; - if (connected.length === 0 && receivedData.length === 0) return 'connecting'; - if (!overallEose) return 'loading'; - if (connected.length === 0 && overallEose) return 'closed'; - if (connected.length > 0 && overallEose && options.stream) return 'live'; - if (connected.length < relays.length && overallEose) return 'partial'; - return 'closed'; - })(); - - return { - status, - connectedCount: connected.length, - eoseCount: states.filter(s => s.subscriptionState === 'eose').length, - errorCount: errors.length, - totalRelays: relays.length, - }; - }, [relayStates, overallEose, relays.length, options.stream]); - - return { - events, - relayStates, - overallState, - loading: !overallEose, - eoseReceived: overallEose, - }; -} -``` - -**Pros**: -- ✅ No duplicate subscriptions -- ✅ Accurate connection tracking -- ✅ Rich per-relay information -- ✅ Works with existing infrastructure -- ✅ Can show "LIVE" only when relays actually connected - -**Cons**: -- ❌ Can't distinguish real EOSE from timeout/error (upstream issue) -- ❌ More complex state management -- ❌ Depends on event metadata having relay URL - -## Recommendation - -**Implement Solution 4 (Hybrid Approach)** as the most pragmatic path forward: - -1. Create `useReqTimelineEnhanced` hook with per-relay state tracking -2. Update ReqViewer to use enhanced hook -3. Improve status indicator logic to use overall state -4. Add per-relay status display in relay dropdown -5. Show accurate indicators for edge cases - -**Future work**: -- Submit PR to applesauce-relay to fix catchError bug -- Add per-relay EOSE tracking to applesauce (upstream enhancement) -- Implement relay health scoring to avoid dead relays - -## Implementation Priority - -### Phase 1: Critical Fixes (Immediate) -1. Implement `useReqTimelineEnhanced` hook -2. Update ReqViewer status indicator logic -3. Add per-relay state display -4. Handle "all relays disconnected" case - -### Phase 2: Enhanced UX (Next) -5. Add per-relay event counts -6. Show relay timing information -7. Add retry/reconnection indicators -8. Integrate with RelayLiveness for smarter relay selection - -### Phase 3: Advanced Features (Future) -9. Partial EOSE indicator (some relays done, some still loading) -10. Relay performance metrics -11. Automatic relay ranking and selection -12. Query optimization suggestions - -## Testing Strategy - -### Unit Tests -- State machine transitions -- Edge case handling -- EOSE aggregation logic - -### Integration Tests -- Real relay connections -- Timeout scenarios -- Mixed success/failure scenarios - -### Manual Testing Scenarios -1. Query with all offline relays -2. Query with mixed offline/online -3. Query with slow relay (>10s response) -4. Mid-query disconnections -5. Streaming mode with gradual disconnections -6. Single relay queries -7. AUTH-required relays -8. Rate-limited relays - -## Metrics to Track - -### User-Visible -- Time to first event -- Time to EOSE per relay -- Events per relay -- Success/failure ratio - -### Debug/Observability -- Relay response times -- Failure reasons -- Retry attempts -- Reconnection events - -## Related Issues - -- RelayLiveness not being checked before connection attempts -- No visual feedback during relay discovery phase -- No indication of AUTH requirements -- No rate limiting awareness - -## References - -- NIP-01: https://github.com/nostr-protocol/nips/blob/master/01.md -- Applesauce-relay docs: (internal node_modules) -- RelayStateManager: `src/services/relay-state-manager.ts` -- useReqTimeline: `src/hooks/useReqTimeline.ts` -- ReqViewer: `src/components/ReqViewer.tsx`