diff --git a/CLAUDE.md b/CLAUDE.md index c5d0910..b68749b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,94 @@ const text = getHighlightText(event); - ❌ 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 `canSign` before signing operations +- Read-only accounts have `canSign: false` and no `signer` + +```typescript +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 `id` parameter 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/` diff --git a/src/hooks/useAccount.ts b/src/hooks/useAccount.ts index bd30c51..4e25797 100644 --- a/src/hooks/useAccount.ts +++ b/src/hooks/useAccount.ts @@ -2,6 +2,19 @@ import { useMemo } from "react"; import { use$ } from "applesauce-react/hooks"; import accounts from "@/services/accounts"; +/** + * Check if an account can sign events + * Read-only accounts cannot sign and should not be prompted for auth + * + * @param account - The account to check (can be undefined) + * @returns true if the account can sign, false otherwise + */ +export function canAccountSign(account: typeof accounts.active): boolean { + if (!account) return false; + const accountType = account.constructor.name; + return accountType !== "ReadonlyAccount"; +} + /** * Hook to access the active account with signing capability detection * @@ -45,18 +58,8 @@ export function useAccount() { // Check if the account has a functional signer // Read-only accounts have a signer that throws errors on sign operations - // We detect this by checking for the ReadonlySigner type or checking signer methods const signer = account.signer; - let canSign = false; - - if (signer) { - // ReadonlyAccount from applesauce-accounts has a ReadonlySigner - // that throws on signEvent, nip04, nip44 operations - // We can detect it by checking if it's an instance with the expected methods - // but we'll use a safer approach: check the account type name - const accountType = account.constructor.name; - canSign = accountType !== "ReadonlyAccount"; - } + const canSign = canAccountSign(account); return { account, diff --git a/src/services/relay-state-manager.ts b/src/services/relay-state-manager.ts index 8d1631f..9cb247d 100644 --- a/src/services/relay-state-manager.ts +++ b/src/services/relay-state-manager.ts @@ -9,6 +9,7 @@ import type { import { transitionAuthState, type AuthEvent } from "@/lib/auth-state-machine"; import { createLogger } from "@/lib/logger"; import { normalizeRelayURL } from "@/lib/relay-url"; +import { canAccountSign } from "@/hooks/useAccount"; import pool from "./relay-pool"; import accountManager from "./accounts"; import db from "./db"; @@ -381,6 +382,11 @@ class RelayStateManager { throw new Error("No active account to authenticate with"); } + // Check if account can sign (read-only accounts cannot authenticate) + if (!canAccountSign(account)) { + throw new Error("Active account cannot sign events (read-only account)"); + } + // Update status to authenticating state.authStatus = "authenticating"; state.stats.authAttemptsCount++; @@ -491,8 +497,9 @@ class RelayStateManager { try { const normalizedUrl = normalizeRelayURL(relayUrl); - // Don't prompt if there's no active account - if (!accountManager.active) return false; + // Don't prompt if there's no active account or account can't sign + const account = accountManager.active; + if (!account || !canAccountSign(account)) return false; // Check permanent preferences const pref = this.authPreferences.get(normalizedUrl);