mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 09:41:13 +02:00
chore: remove old docs
This commit is contained in:
@@ -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<HTMLElement | null>(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<AnnouncerContextValue | null>(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 (
|
||||
<AnnouncerContext.Provider value={{ announce }}>
|
||||
{children}
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{politeMessage}
|
||||
</div>
|
||||
<div
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{assertiveMessage}
|
||||
</div>
|
||||
</AnnouncerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{description && (
|
||||
<span id={descriptionId} className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
{React.cloneElement(children as React.ReactElement, {
|
||||
id,
|
||||
'aria-describedby': [
|
||||
description ? descriptionId : null,
|
||||
error ? errorId : null,
|
||||
].filter(Boolean).join(' ') || undefined,
|
||||
'aria-invalid': !!error,
|
||||
})}
|
||||
{error && (
|
||||
<span id={errorId} role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**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 (
|
||||
<label {...props}>
|
||||
{children}
|
||||
{required && <span aria-hidden="true" className="text-destructive ml-1">*</span>}
|
||||
{required && <span className="sr-only"> (required)</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Component ARIA Improvements (Priority: Medium)
|
||||
|
||||
### 3.1 Event Renderers
|
||||
|
||||
**Base pattern for all renderers**:
|
||||
|
||||
```typescript
|
||||
// BaseEventRenderer.tsx additions
|
||||
<article
|
||||
aria-label={`${kindName} by ${authorName}`}
|
||||
aria-describedby={`event-${event.id}-content`}
|
||||
>
|
||||
<header>
|
||||
<UserName pubkey={pubkey} aria-label={`Author: ${displayName}`} />
|
||||
<time dateTime={isoDate} aria-label={`Posted ${relativeTime}`}>
|
||||
{relativeTime}
|
||||
</time>
|
||||
</header>
|
||||
<div id={`event-${event.id}-content`}>
|
||||
{children}
|
||||
</div>
|
||||
</article>
|
||||
```
|
||||
|
||||
**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
|
||||
<section aria-label="Event timeline" role="feed" aria-busy={loading}>
|
||||
<h2 className="sr-only">Timeline</h2>
|
||||
{events.map((event, index) => (
|
||||
<article
|
||||
key={event.id}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={events.length}
|
||||
>
|
||||
<KindRenderer event={event} />
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
```
|
||||
|
||||
**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
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={contentId}
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
<div
|
||||
id={contentId}
|
||||
aria-hidden={!isOpen}
|
||||
role="region"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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/)
|
||||
178
AGENTS.md
178
AGENTS.md
@@ -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.*
|
||||
@@ -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<T>(
|
||||
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<T>(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<T>(filters: T): T {
|
||||
return useMemo(() => filters, [JSON.stringify(filters)]);
|
||||
}
|
||||
|
||||
// AFTER - use isFilterEqual for comparison
|
||||
import { isFilterEqual } from "applesauce-core/helpers";
|
||||
|
||||
export function useStableFilters<T>(filters: T): T {
|
||||
const prevFiltersRef = useRef<T>();
|
||||
|
||||
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)
|
||||
@@ -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<number, ComponentType> = {
|
||||
0: ProfileRenderer,
|
||||
1: NoteRenderer,
|
||||
// 40+ kinds...
|
||||
};
|
||||
|
||||
export function KindRenderer({ event }) {
|
||||
const Renderer = kindRenderers[event.kind] || DefaultKindRenderer;
|
||||
return <Renderer event={event} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### 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<T>(value: T, serialize?: (v: T) => string): T {
|
||||
const serialized = serialize?.(value) ?? JSON.stringify(value);
|
||||
return useMemo(() => value, [serialized]);
|
||||
}
|
||||
|
||||
export function useStableArray<T>(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
|
||||
<Input
|
||||
id="relay-url"
|
||||
aria-describedby={error ? "relay-url-error" : undefined}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
{error && (
|
||||
<span id="relay-url-error" role="alert" className="text-destructive">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 4.3 Live Regions
|
||||
```typescript
|
||||
// Announce dynamic updates
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{statusMessage}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 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 = () => (
|
||||
<Dialog open={isFirstVisit}>
|
||||
<DialogContent>
|
||||
<h2>Welcome to Grimoire</h2>
|
||||
<p>Press ⌘K to get started...</p>
|
||||
<InteractiveDemo />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
```
|
||||
|
||||
#### 5.2 Undo System
|
||||
```typescript
|
||||
// Track recent actions for undo
|
||||
const undoStack = atom<Action[]>([]);
|
||||
|
||||
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 <MobileNotSupported />;
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
93
GEMINI.md
93
GEMINI.md
@@ -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).
|
||||
@@ -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<NostrEvent> {
|
||||
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<void> {
|
||||
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<T>` → returns `T` (never undefined)
|
||||
- `Observable<T>` → 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)
|
||||
@@ -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
|
||||
- `<alias>` → 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<WizardState>;
|
||||
}
|
||||
```
|
||||
|
||||
**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<LocalSpell[]>([]);
|
||||
export const publishedSpellsAtom = atom<ParsedSpell[]>([]);
|
||||
export const spellDiscoveryAtom = atom<ParsedSpell[]>([]);
|
||||
```
|
||||
|
||||
**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<any>("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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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", <subscription_id>, <filter1>, <filter2>, ...]`
|
||||
2. Relay responds with zero or more: `["EVENT", <subscription_id>, <event>]`
|
||||
3. Relay sends: `["EOSE", <subscription_id>]` when initial query complete
|
||||
4. Client can keep subscription open for streaming
|
||||
5. Client closes: `["CLOSE", <subscription_id>]`
|
||||
6. Relay can close: `["CLOSED", <subscription_id>, <reason>]`
|
||||
|
||||
### 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<SubscriptionResponse>`
|
||||
- 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<Map<string, RelaySubscriptionState>>();
|
||||
|
||||
// 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<Set<string>>(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<Map<string, ReqRelayState>>(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`
|
||||
Reference in New Issue
Block a user