* fix: only prompt relay auth for accounts that can sign - Add canAccountSign() helper to check if account is read-only - Block auth prompts for read-only accounts in shouldPromptAuth() - Throw error when authenticateRelay() called with read-only account - Document all major app hooks in CLAUDE.md for future reference Read-only accounts cannot sign events, so they should never be prompted for relay authentication or attempt to authenticate. This prevents confusing UX where users are asked to sign but cannot. * refactor: extract canAccountSign helper to useAccount - Move canAccountSign function from relay-state-manager to useAccount.ts - Import and reuse the shared helper in relay-state-manager - Update useAccount hook to use the extracted helper internally - Follows DRY principle by centralizing account sign capability logic This keeps the account sign capability detection logic in one place, making it easier to maintain and ensuring consistency across the app. --------- Co-authored-by: Claude <noreply@anthropic.com>
17 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Grimoire is a Nostr protocol explorer and developer tool. It's 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 Cmd+K palette.
Stack: React 19 + TypeScript + Vite + TailwindCSS + Jotai + Dexie + Applesauce
Core Architecture
Dual State System
UI State (src/core/state.ts + src/core/logic.ts):
- Jotai atom persisted to localStorage
- Pure functions for all mutations:
(state, payload) => newState - Manages workspaces, windows, layout tree, active account
Nostr State (src/services/event-store.ts):
- Singleton
EventStorefrom applesauce-core - Single source of truth for all Nostr events
- Reactive: components subscribe via hooks, auto-update on new events
- Handles replaceable events automatically (profiles, contact lists, etc.)
Relay State (src/services/relay-liveness.ts):
- Singleton
RelayLivenesstracks relay health across sessions - Persisted to Dexie
relayLivenesstable - Maintains failure counts, backoff states, last success/failure times
- Prevents repeated connection attempts to dead relays
Nostr Query State Machine (src/lib/req-state-machine.ts + src/hooks/useReqTimelineEnhanced.ts):
- Accurate tracking of REQ subscriptions across multiple relays
- Distinguishes between
LIVE,LOADING,PARTIAL,OFFLINE,CLOSED, andFAILEDstates - Solves "LIVE with 0 relays" bug by tracking per-relay connection state and event counts
- Pattern: Subscribe to relays individually to detect per-relay EOSE and errors
Critical: Don't create new EventStore, RelayPool, or RelayLiveness instances - use the singletons in src/services/
Event Loading (src/services/loaders.ts):
- Unified loader auto-fetches missing events when queried via
eventStore.event()oreventStore.replaceable() - Custom
eventLoader()with smart relay hint merging for explicit loading with context addressLoaderandprofileLoaderfor replaceable events with batchingcreateTimelineLoaderfor paginated feeds
Action System (src/services/hub.ts):
ActionRunner(v5) executes actions with signing and publishing- Actions are async functions:
async ({ factory, sign, publish }) => { ... } - Use
await publish(event)to publish (not generators/yield)
Window System
Windows are rendered in a recursive binary split layout (via react-mosaic-component):
- Each window has:
id(UUID),appId(type identifier),title,props - Layout is a tree: leaf nodes are window IDs, branch nodes split space
- Never manipulate layout tree directly - use callbacks from mosaic
Workspaces are virtual desktops, each with its own layout tree.
Command System
src/types/man.ts defines all commands as Unix man pages:
- Each command has an
appId(which app to open) andargParser(CLI → props) - Parsers can be async (e.g., resolving NIP-05 addresses)
- Command pattern: user types
profile alice@example.com→ parser resolves → opens ProfileViewer with props
Global Flags (src/lib/global-flags.ts):
- Global flags work across ALL commands and are extracted before command-specific parsing
--title "Custom Title"- Override the window title (supports quotes, emoji, Unicode)- Example:
profile alice --title "👤 Alice" - Example:
req -k 1 -a npub... --title "My Feed" - Position independent: can appear before, after, or in the middle of command args
- Example:
- Tokenization uses
shell-quotelibrary for proper quote/whitespace handling - Display priority:
customTitle>dynamicTitle(from DynamicWindowTitle) >appId.toUpperCase()
Reactive Nostr Pattern
Applesauce uses RxJS observables for reactive data flow:
- Events arrive from relays → added to EventStore
- Queries/hooks subscribe to EventStore observables
- Components re-render automatically when events update
- Replaceable events (kind 0, 3, 10000-19999, 30000-39999) auto-replace older versions
Use hooks like useProfile(), useNostrEvent(), useTimeline() - they handle subscriptions.
The use$ Hook (applesauce v5):
import { use$ } from "applesauce-react/hooks";
// Direct observable (for BehaviorSubjects - never undefined)
const account = use$(accounts.active$);
// Factory with deps (for dynamic observables)
const event = use$(() => eventStore.event(eventId), [eventId]);
const timeline = use$(() => eventStore.timeline(filters), [filters]);
Applesauce Helpers & Caching
Critical Performance Insight: Applesauce helpers cache computed values internally using symbols. You don't need useMemo when calling applesauce helpers.
// ❌ WRONG - Unnecessary memoization
const title = useMemo(() => getArticleTitle(event), [event]);
const text = useMemo(() => getHighlightText(event), [event]);
// ✅ CORRECT - Helpers cache internally
const title = getArticleTitle(event);
const text = getHighlightText(event);
How it works: Helpers use getOrComputeCachedValue(event, symbol, compute) to cache results on the event object. The first call computes and caches, subsequent calls return the cached value instantly.
Available Helpers (split between packages in applesauce v5):
From applesauce-core/helpers (protocol-level):
- Tags:
getTagValue(event, name)- get single tag value (searches hidden tags first) - Profile:
getProfileContent(event),getDisplayName(metadata, fallback) - Pointers:
parseCoordinate(aTag),getEventPointerFromETag,getAddressPointerFromATag,getProfilePointerFromPTag - Filters:
isFilterEqual(a, b),matchFilter(filter, event),mergeFilters(...filters) - Relays:
getSeenRelays,mergeRelaySets,getInboxes,getOutboxes - URL:
normalizeURL
From applesauce-common/helpers (social/NIP-specific):
- Article:
getArticleTitle,getArticleSummary,getArticleImage,getArticlePublished - Highlight:
getHighlightText,getHighlightSourceUrl,getHighlightSourceEventPointer,getHighlightSourceAddressPointer,getHighlightContext,getHighlightComment - Threading:
getNip10References(event)- parses NIP-10 thread tags - Comment:
getCommentReplyPointer(event)- parses NIP-22 comment replies - Zap:
getZapAmount,getZapSender,getZapRecipient,getZapComment - Reactions:
getReactionEventPointer(event),getReactionAddressPointer(event) - Lists:
getRelaysFromList
Custom Grimoire Helpers (not in applesauce):
getTagValues(event, name)- plural version to get array of tag values (src/lib/nostr-utils.ts)resolveFilterAliases(filter, pubkey, contacts)- resolves$me/$contactsaliases (src/lib/nostr-utils.ts)getDisplayName(pubkey, metadata)- enhanced version with pubkey fallback (src/lib/nostr-utils.ts)- NIP-34 git helpers (src/lib/nip34-helpers.ts) - wraps
getTagValuefor repository, issue, patch metadata - NIP-C0 code snippet helpers (src/lib/nip-c0-helpers.ts) - wraps
getTagValuefor code metadata
When to use useMemo:
- ✅ Complex transformations not using applesauce helpers (sorting, filtering, mapping)
- ✅ Creating objects/arrays for dependency tracking (options, configurations)
- ✅ Expensive computations that don't call applesauce helpers
- ❌ Direct calls to applesauce helpers (they cache internally)
- ❌ Grimoire helpers that wrap
getTagValue(caching propagates)
Major Hooks
Grimoire provides custom React hooks for common Nostr operations. All hooks handle cleanup automatically.
Account & Authentication
useAccount() (src/hooks/useAccount.ts):
- Access active account with signing capability detection
- Returns:
{ account, pubkey, canSign, signer, isLoggedIn } - Critical: Always check
canSignbefore signing operations - Read-only accounts have
canSign: falseand nosigner
const { canSign, signer, pubkey } = useAccount();
if (canSign) {
// Can sign and publish events
await signer.signEvent(event);
} else {
// Show "log in to post" message
}
Nostr Data Fetching
useProfile(pubkey, relayHints?) (src/hooks/useProfile.ts):
- Fetch and cache user profile metadata (kind 0)
- Loads from IndexedDB first (fast), then network
- Uses AbortController to prevent race conditions
- Returns:
ProfileContent | undefined
useNostrEvent(pointer, context?) (src/hooks/useNostrEvent.ts):
- Unified hook for fetching events by ID, EventPointer, or AddressPointer
- Supports relay hints via context (pubkey string or full event)
- Auto-loads missing events using smart relay selection
- Returns:
NostrEvent | undefined
useTimeline(id, filters, relays, options?) (src/hooks/useTimeline.ts):
- Subscribe to timeline of events matching filters
- Uses applesauce loaders for efficient caching
- Returns:
{ events, loading, error } - The
idparameter is for caching (use stable string)
Relay Management
useRelayState() (src/hooks/useRelayState.ts):
- Access global relay state and auth management
- Returns relay connection states, pending auth challenges, preferences
- Methods:
authenticateRelay(),rejectAuth(),setAuthPreference() - Automatically subscribes to relay state updates
useRelayInfo(relayUrl) (src/hooks/useRelayInfo.ts):
- Fetch NIP-11 relay information document
- Cached in IndexedDB with 24-hour TTL
- Returns:
RelayInfo | undefined
useOutboxRelays(pubkey) (src/hooks/useOutboxRelays.ts):
- Get user's outbox relays from kind 10002 relay list
- Cached via RelayListCache for performance
- Returns:
string[] | undefined
Advanced Hooks
useReqTimelineEnhanced(filter, relays, options) (src/hooks/useReqTimelineEnhanced.ts):
- Enhanced timeline with accurate state tracking
- Tracks per-relay EOSE and connection state
- Returns:
{ events, state, relayStates, stats } - Use for REQ viewer and advanced timeline UIs
useNip05(nip05Address) (src/hooks/useNip05.ts):
- Resolve NIP-05 identifier to pubkey
- Cached with 1-hour TTL
- Returns:
{ pubkey, relays, loading, error }
useNip19Decode(nip19String) (src/hooks/useNip19Decode.ts):
- Decode nprofile, nevent, naddr, note, npub strings
- Returns:
{ type, data, error }
Utility Hooks
useStableValue(value) / useStableArray(array) (src/hooks/useStable.ts):
- Prevent unnecessary re-renders from deep equality
- Use for filters, options, relay arrays
- Returns stable reference when deep-equal
useCopy() (src/hooks/useCopy.ts):
- Copy text to clipboard with toast feedback
- Returns:
{ copy, copied }function and state
Key Conventions
- Path Alias:
@/=./src/ - Styling: Tailwind + HSL CSS variables (theme tokens defined in
index.css) - Types: Prefer types from
applesauce-core, extend insrc/types/when needed - File Organization: By domain (
nostr/,ui/,services/,hooks/,lib/) - State Logic: All UI state mutations go through
src/core/logic.tspure functions
Important Patterns
Adding New Commands:
- Add entry to
manPagesinsrc/types/man.ts - Create parser in
src/lib/*-parser.tsif argument parsing needed - Create viewer component for the
appId - Wire viewer into window rendering (
WindowTitle.tsx)
Working with Nostr Data:
- Event data comes from singleton EventStore (reactive)
- Metadata cached in Dexie (
src/services/db.ts) for offline access - Active account stored in Jotai state, synced via
useAccountSynchook - Use inbox/outbox relay pattern for user relay lists
Event Rendering:
- Feed renderers:
KindRenderercomponent withrenderersregistry insrc/components/nostr/kinds/index.tsx - Detail renderers:
DetailKindRenderercomponent withdetailRenderersregistry - Registry pattern allows adding new kind renderers without modifying parent components
- Falls back to
DefaultKindRendereror feed renderer for unregistered kinds - Naming Convention: Use human-friendly names for renderers (e.g.,
LiveActivityRendererinstead ofKind30311Renderer) to make code understandable without memorizing kind numbers- Feed renderer:
[Name]Renderer.tsx(e.g.,LiveActivityRenderer.tsx) - Detail renderer:
[Name]DetailRenderer.tsx(e.g.,LiveActivityDetailRenderer.tsx)
- Feed renderer:
Mosaic Layout:
- Layout mutations via
updateLayout()callback only - Don't traverse or modify layout tree manually
- Adding/removing windows handled by
logic.tsfunctions
Error Boundaries:
- All event renderers wrapped in
EventErrorBoundarycomponent - Prevents one broken event from crashing entire feed or detail view
- Provides diagnostic UI with retry capability and error details
- Error boundaries auto-reset when event changes
Chat System
Current Status: Only NIP-29 (relay-based groups) is supported. Other protocols are planned for future releases.
Architecture: Protocol adapter pattern for supporting multiple Nostr messaging protocols:
src/lib/chat/adapters/base-adapter.ts- Base interface all adapters implementsrc/lib/chat/adapters/nip-29-adapter.ts- NIP-29 relay groups (currently enabled)- Other adapters (NIP-C7, NIP-17, NIP-28, NIP-53) are implemented but commented out
NIP-29 Group Format: relay'group-id (wss:// prefix optional)
- Examples:
relay.example.com'bitcoin-dev,wss://nos.lol'welcome - Groups are hosted on a single relay that enforces membership and moderation
- Messages are kind 9, metadata is kind 39000, admins are kind 39001, members are kind 39002
Key Components:
src/components/ChatViewer.tsx- Main chat interface (protocol-agnostic)src/components/chat/ReplyPreview.tsx- Shows reply context with scroll-to functionalitysrc/lib/chat-parser.ts- Auto-detects protocol from identifier formatsrc/types/chat.ts- Protocol-agnostic types (Conversation, Message, etc.)
Usage:
chat relay.example.com'bitcoin-dev # Join NIP-29 group
chat wss://nos.lol'welcome # Join with explicit wss:// prefix
Adding New Protocols (for future work):
- Create new adapter extending
ChatProtocolAdapterinsrc/lib/chat/adapters/ - Implement all required methods (parseIdentifier, resolveConversation, loadMessages, sendMessage)
- Uncomment adapter registration in
src/lib/chat-parser.tsandsrc/components/ChatViewer.tsx - Update command docs in
src/types/man.tsif needed
Testing
Test Framework: Vitest with node environment
Running Tests:
npm test # Watch mode (auto-runs on file changes)
npm run test:ui # Visual UI for test exploration
npm run test:run # Single run (CI mode)
Test Conventions:
- Test files:
*.test.tsor*.test.tsxcolocated with source files - Focus on testing pure functions and parsing logic
- Use descriptive test names that explain behavior
- Group related tests with
describeblocks
What to Test:
- Parsers (
src/lib/*-parser.ts): All argument parsing logic, edge cases, validation - Pure functions (
src/core/logic.ts): State mutations, business logic - Utilities (
src/lib/*.ts): Helper functions, data transformations - Not UI components: React components tested manually (for now)
Example Test Structure:
describe("parseReqCommand", () => {
describe("kind flag (-k, --kind)", () => {
it("should parse single kind", () => {
const result = parseReqCommand(["-k", "1"]);
expect(result.filter.kinds).toEqual([1]);
});
it("should deduplicate kinds", () => {
const result = parseReqCommand(["-k", "1,3,1"]);
expect(result.filter.kinds).toEqual([1, 3]);
});
});
});
Verification Requirements
CRITICAL: Before marking any task complete, verify changes work correctly:
- For any code change: Run
npm run test:run- tests must pass - For UI changes: Run
npm run build- build must succeed - For style/lint changes: Run
npm run lint- no new errors
Quick verification command:
npm run lint && npm run test:run && npm run build
If tests fail, fix the issues before proceeding. Never leave broken tests or a failing build.
Slash Commands
Use these commands for common workflows:
/verify- Run full verification suite (lint + test + build)/test- Run tests and report results/lint-fix- Auto-fix lint and formatting issues/commit-push-pr- Create a commit and PR with proper formatting/review- Review changes for quality and Nostr best practices
Critical Notes
- React 19 features in use (ensure compatibility)
- LocalStorage persistence has quota handling built-in
- Dark mode is default (controlled via HTML class)
- EventStore handles event deduplication and replaceability automatically
- Run tests before committing changes to parsers or core logic
- Always run
/verifybefore creating a PR