diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..fd97854 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,13 @@ +[mcp_servers.js-dev] +args = [ + "-y", + "@soapbox.pub/js-dev-mcp@latest", +] +command = "npx" + +[mcp_servers.nostr] +args = [ + "-y", + "@nostrbook/mcp@latest", +] +command = "npx" diff --git a/AGENTS.md b/AGENTS.md index 4ec9e5c..3fd0da4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,907 +1,298 @@ # Project Overview -This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify. +This project is a Nostr client application built with React 19.x, TailwindCSS 4.x, Vite, shadcn/ui, and Nostrify. ## Technology Stack -- **React 18.x**: Stable version of React with hooks, concurrent rendering, and improved performance -- **TailwindCSS 3.x**: Utility-first CSS framework for styling -- **Vite**: Fast build tool and development server -- **shadcn/ui**: Unstyled, accessible UI components built with Radix UI and Tailwind -- **Nostrify**: Nostr protocol framework for Deno and web -- **React Router**: For client-side routing with BrowserRouter and ScrollToTop functionality -- **TanStack Query**: For data fetching, caching, and state management -- **TypeScript**: For type-safe JavaScript development +- **React 19.x**: hooks, concurrent rendering, ref-as-prop +- **TailwindCSS 4.x**: utility-first styling +- **Vite**: dev server and production bundler +- **shadcn/ui**: unstyled accessible components on Radix UI + Tailwind (48+ components in `@/components/ui`) +- **Nostrify** (`@nostrify/react`): Nostr protocol framework +- **React Router**: client-side routing with `BrowserRouter` and automatic scroll-to-top +- **TanStack Query**: data fetching, caching, state +- **TypeScript**: type-safe JS. **Never use the `any` type.** ## Project Structure -- `/docs/`: Specialized documentation for implementation patterns and features -- `/src/components/`: UI components including NostrProvider for Nostr integration - - `/src/components/ui/`: shadcn/ui components (48+ components available) - - `/src/components/auth/`: Authentication-related components (LoginArea, LoginDialog, etc.) - - `/src/components/dm/`: Direct messaging UI components (DMMessagingInterface, DMConversationList, DMChatArea) - - Zap components: `ZapButton`, `ZapDialog`, `WalletModal` for Lightning payments -- `/src/hooks/`: Custom hooks including: - - `useNostr`: Core Nostr protocol integration - - `useAuthor`: Fetch user profile data by pubkey - - `useCurrentUser`: Get currently logged-in user - - `useNostrPublish`: Publish events to Nostr - - `useUploadFile`: Upload files via Blossom servers - - `useAppContext`: Access global app configuration - - `useTheme`: Theme management - - `useToast`: Toast notifications - - `useLocalStorage`: Persistent local storage - - `useLoggedInAccounts`: Manage multiple accounts - - `useLoginActions`: Authentication actions - - `useIsMobile`: Responsive design helper - - `useZaps`: Lightning zap functionality with payment processing - - `useWallet`: Unified wallet detection (WebLN + NWC) - - `useNWC`: Nostr Wallet Connect connection management - - `useNWCContext`: Access NWC context provider - - `useShakespeare`: AI chat completions with Shakespeare AI API -- `/src/pages/`: Page components used by React Router (Index, NotFound) -- `/src/lib/`: Utility functions and shared logic -- `/src/contexts/`: React context providers (AppContext, NWCContext, DMContext) - - `useDMContext`: Hook exported from DMContext for direct messaging (NIP-04 & NIP-17) - - `useConversationMessages`: Hook exported from DMContext for paginated messages -- `/src/test/`: Testing utilities including TestApp component -- `/public/`: Static assets -- `App.tsx`: Main app component with provider setup (**CRITICAL**: this file is **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider` and other important providers - **read this file before making changes**. Changes are usually not necessary unless adding new providers. Changing this file may break the application) -- `AppRouter.tsx`: React Router configuration +- `/src/components/` — UI components. `ui/` holds shadcn/ui primitives; `auth/` holds login components (`LoginArea`, `AuthDialog`, `AccountSwitcher`). +- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Key ones: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`. +- `/src/pages/` — page components wired into React Router (`Index`, `NotFound`, `NIP19Page`). +- `/src/lib/` — utility functions and shared logic. +- `/src/contexts/` — React context providers (`AppContext`). +- `/src/test/` — testing utilities including the `TestApp` wrapper. +- `/public/` — static assets. +- `App.tsx` — **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider`, `AppProvider`, `NostrLoginProvider`. **Read before editing**; changes are rarely needed. +- `AppRouter.tsx` — React Router configuration. The catch-all `/:nip19` route handles all NIP-19 identifiers (see the `nip19-routing` skill). -**CRITICAL**: Always read the files mentioned above before making changes, as they contain important setup and configuration for the application. Never directly write to these files without first reading their contents. +**Always read an existing file before modifying it.** Never write over `App.tsx`, `AppRouter.tsx`, or `NostrProvider` without first reading their contents. ## UI Components -The project uses shadcn/ui components located in `@/components/ui`. These are unstyled, accessible components built with Radix UI and styled with Tailwind CSS. Available components include: - -- **Accordion**: Vertically collapsing content panels -- **Alert**: Displays important messages to users -- **AlertDialog**: Modal dialog for critical actions requiring confirmation -- **AspectRatio**: Maintains consistent width-to-height ratio -- **Avatar**: User profile pictures with fallback support -- **Badge**: Small status descriptors for UI elements -- **Breadcrumb**: Navigation aid showing current location in hierarchy -- **Button**: Customizable button with multiple variants and sizes -- **Calendar**: Date picker component -- **Card**: Container with header, content, and footer sections -- **Carousel**: Slideshow for cycling through elements -- **Chart**: Data visualization component -- **Checkbox**: Selectable input element -- **Collapsible**: Toggle for showing/hiding content -- **Command**: Command palette for keyboard-first interfaces -- **ContextMenu**: Right-click menu component -- **Dialog**: Modal window overlay -- **Drawer**: Side-sliding panel (using vaul) -- **DropdownMenu**: Menu that appears from a trigger element -- **Form**: Form validation and submission handling -- **HoverCard**: Card that appears when hovering over an element -- **InputOTP**: One-time password input field -- **Input**: Text input field -- **Label**: Accessible form labels -- **Menubar**: Horizontal menu with dropdowns -- **NavigationMenu**: Accessible navigation component -- **Pagination**: Controls for navigating between pages -- **Popover**: Floating content triggered by a button -- **Progress**: Progress indicator -- **RadioGroup**: Group of radio inputs -- **Resizable**: Resizable panels and interfaces -- **ScrollArea**: Scrollable container with custom scrollbars -- **Select**: Dropdown selection component -- **Separator**: Visual divider between content -- **Sheet**: Side-anchored dialog component -- **Sidebar**: Navigation sidebar component -- **Skeleton**: Loading placeholder -- **Slider**: Input for selecting a value from a range -- **Switch**: Toggle switch control -- **Table**: Data table with headers and rows -- **Tabs**: Tabbed interface component -- **Textarea**: Multi-line text input -- **Toast**: Toast notification component -- **ToggleGroup**: Group of toggle buttons -- **Toggle**: Two-state button -- **Tooltip**: Informational text that appears on hover - -These components follow a consistent pattern using React's `forwardRef` and use the `cn()` utility for class name merging. Many are built on Radix UI primitives for accessibility and customized with Tailwind CSS. - -## Documentation - -The project includes a **`docs/`** directory containing specialized documentation for specific implementation tasks. You are encouraged to add new documentation files to help future development. - -- **`docs/AI_CHAT.md`**: Read when building any AI-powered chat interfaces, implementing streaming responses, or integrating with the Shakespeare API. - -- **`docs/NOSTR_COMMENTS.md`**: Read when implementing comment systems, adding discussion features to posts/articles, or building community interaction features. - -- **`docs/NOSTR_INFINITE_SCROLL.md`**: Read when building feed interfaces, implementing pagination for Nostr events, or creating social media-style infinite scroll experiences. - -- **`docs/NOSTR_DIRECT_MESSAGES.md`**: Read when implementing direct messaging features, building chat interfaces, or working with encrypted peer-to-peer communication (NIP-04 and NIP-17). +Components in `@/components/ui` are unstyled, accessible primitives styled with Tailwind. They follow a consistent React 19 pattern: plain function components that type props via `React.ComponentProps<...>` and forward `ref` as a normal prop (no `React.forwardRef`), tag their root with a `data-slot` attribute, merge classes with the `cn()` utility, and define variants with `class-variance-authority`. Components built on Radix import from the unified `radix-ui` package (e.g. `import { Dialog as DialogPrimitive } from "radix-ui"`), not individual `@radix-ui/react-*` packages. When you need a specific component, list the directory (`ls src/components/ui/`) or import from `@/components/ui/` — all common primitives are present (buttons, inputs, dialogs, dropdowns, forms, tables, etc.). ## System Prompt Management -The AI assistant's behavior and knowledge is defined by the AGENTS.md file, which serves as the system prompt. To modify the assistant's instructions or add new project-specific guidelines: - -1. Edit AGENTS.md directly -2. The changes take effect in the next session +The assistant's behavior is defined by this file (`AGENTS.md`). Edit it directly to change guidelines — updates take effect the next session. Specialized workflows live in `/.agents/skills/` as loadable skills, discoverable through the `skill` tool. ## Nostr Protocol Integration -This project comes with custom hooks for querying and publishing events on the Nostr network. +### When to reuse an existing NIP vs. create a new kind -### Nostr Implementation Guidelines - -- Always check the full list of existing NIPs before implementing any Nostr features to see what kinds are currently in use across all NIPs. -- If any existing kind or NIP might offer the required functionality, read the relevant NIPs to investigate thoroughly. Several NIPs may need to be read before making a decision. -- Only generate new kind numbers if no existing suitable kinds are found after comprehensive research. - -Knowing when to create a new kind versus reusing an existing kind requires careful judgement. Introducing new kinds means the project won't be interoperable with existing clients. But deviating too far from the schema of a particular kind can cause different interoperability issues. - -#### Choosing Between Existing NIPs and Custom Kinds - -When implementing features that could use existing NIPs, follow this decision framework: - -1. **Thorough NIP Review**: Before considering a new kind, always perform a comprehensive review of existing NIPs and their associated kinds. Get an overview of all NIPs, and then read specific NIPs and kind documentation to investigate any potentially relevant NIPs or kinds in detail. The goal is to find the closest existing solution. - -2. **Prioritize Existing NIPs**: Always prefer extending or using existing NIPs over creating custom kinds, even if they require minor compromises in functionality. - -3. **Interoperability vs. Perfect Fit**: Consider the trade-off between: - - **Interoperability**: Using existing kinds means compatibility with other Nostr clients - - **Perfect Schema**: Custom kinds allow perfect data modeling but create ecosystem fragmentation - -4. **Extension Strategy**: When existing NIPs are close but not perfect: - - Use the existing kind as the base - - Add domain-specific tags for additional metadata - - Document the extensions in `NIP.md` - -5. **When to Generate Custom Kinds**: - - No existing NIP covers the core functionality - - The data structure is fundamentally different from existing patterns - - The use case requires different storage characteristics (regular vs replaceable vs addressable) - - If you have a tool available to generate a kind, you **MUST** call the tool to generate a new kind rather than picking an arbitrary number - -6. **Custom Kind Publishing**: When publishing events with custom generated kinds, always include a NIP-31 "alt" tag with a human-readable description of the event's purpose. - -**Example Decision Process**: -``` -Need: Equipment marketplace for farmers -Options: -1. NIP-15 (Marketplace) - Too structured for peer-to-peer sales -2. NIP-99 (Classified Listings) - Good fit, can extend with farming tags -3. Custom kind - Perfect fit but no interoperability - -Decision: Use NIP-99 + farming-specific tags for best balance -``` - -#### Tag Design Principles - -When designing tags for Nostr events, follow these principles: - -1. **Kind vs Tags Separation**: - - **Kind** = Schema/structure (how the data is organized) - - **Tags** = Semantics/categories (what the data represents) - - Don't create different kinds for the same data structure - -2. **Use Single-Letter Tags for Categories**: - - **Relays only index single-letter tags** for efficient querying - - Use `t` tags for categorization, not custom multi-letter tags - - Multiple `t` tags allow items to belong to multiple categories - -3. **Relay-Level Filtering**: - - Design tags to enable efficient relay-level filtering with `#t: ["category"]` - - Avoid client-side filtering when relay-level filtering is possible - - Consider query patterns when designing tag structure - -4. **Tag Examples**: - ```json - // ❌ Wrong: Multi-letter tag, not queryable at relay level - ["product_type", "electronics"] - - // ✅ Correct: Single-letter tag, relay-indexed and queryable - ["t", "electronics"] - ["t", "smartphone"] - ["t", "android"] - ``` - -5. **Querying Best Practices**: - ```typescript - // ❌ Inefficient: Get all events, filter in JavaScript - const events = await nostr.query([{ kinds: [30402] }]); - const filtered = events.filter(e => hasTag(e, 'product_type', 'electronics')); - - // ✅ Efficient: Filter at relay level - const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]); - ``` - -#### `t` Tag Filtering for Community-Specific Content - -For applications focused on a specific community or niche, you can use `t` tags to filter events for the target audience. - -**When to Use:** -- ✅ Community apps: "farmers" → `t: "farming"`, "Poland" → `t: "poland"` -- ❌ Generic platforms: Twitter clones, general Nostr clients - -**Implementation:** -```typescript -// Publishing with community tag -createEvent({ - kind: 1, - content: data.content, - tags: [['t', 'farming']] -}); - -// Querying community content -const events = await nostr.query([{ - kinds: [1], - '#t': ['farming'], - limit: 20 -}], { signal }); -``` +1. **Always review existing NIPs first.** Use the NIP index tool, then read candidate NIPs in detail. The goal is to find the closest existing solution. +2. **Prefer extending existing NIPs** over creating custom kinds, even if it requires minor schema compromises. Custom kinds fragment the ecosystem. +3. **When existing NIPs are close but not perfect**, use the existing kind as the base and add domain-specific tags. Document extensions in `NIP.md`. +4. **Only generate a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case needs different storage characteristics (regular/replaceable/addressable). +5. **If a tool to generate a new kind number is available, you MUST use it** — don't pick an arbitrary number. +6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description. ### Kind Ranges -An event's kind number determines the event's behavior and storage characteristics: +- **Regular** (1000 ≤ kind < 10000): stored permanently by relays. Notes, articles, etc. +- **Replaceable** (10000 ≤ kind < 20000): only the latest event per `pubkey+kind` is stored. Profile metadata, contact lists. +- **Addressable** (30000 ≤ kind < 40000): identified by `pubkey+kind+d-tag`; only the latest per combo is stored. Articles, long-form content. -- **Regular Events** (1000 ≤ kind < 10000): Expected to be stored by relays permanently. Used for persistent content like notes, articles, etc. -- **Replaceable Events** (10000 ≤ kind < 20000): Only the latest event per pubkey+kind combination is stored. Used for profile metadata, contact lists, etc. -- **Addressable Events** (30000 ≤ kind < 40000): Identified by pubkey+kind+d-tag combination, only latest per combination is stored. Used for articles, long-form content, etc. +Kinds below 1000 are "legacy"; their storage behavior is per-kind (e.g. kind 1 is regular, kind 3 is replaceable). -Kinds below 1000 are considered "legacy" kinds, and may have different storage characteristics based on their kind definition. For example, kind 1 is regular, while kind 3 is replaceable. +### Tag Design Principles -### Content Field Design Principles +- **Kind = schema, tags = semantics.** Don't create new kinds just to represent a different category of the same data. +- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering. +- **Filter at the relay.** Pass tag filters in the query rather than fetching everything and filtering in JS. +- **For community/niche apps**, tag events with a `t` and query by it: `createEvent({ kind: 1, content, tags: [['t', 'farming']] })`, then `nostr.query([{ kinds: [1], '#t': ['farming'] }])`. Don't do this for generic platforms. -When designing new event kinds, the `content` field should be used for semantically important data that doesn't need to be queried by relays. **Structured JSON data generally shouldn't go in the content field** (kind 0 being an early exception). +### Content Field Design -#### Guidelines - -- **Use content for**: Large text, freeform human-readable content, or existing industry-standard JSON formats (Tiled maps, FHIR, GeoJSON) -- **Use tags for**: Queryable metadata, structured data, anything that needs relay-level filtering -- **Empty content is valid**: Many events need only tags with `content: ""` -- **Relays only index tags**: If you need to filter by a field, it must be a tag - -#### Example - -**✅ Good - queryable data in tags:** -```json -{ - "kind": 30402, - "content": "", - "tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] -} -``` - -**❌ Bad - structured data in content:** -```json -{ - "kind": 30402, - "content": "{\"title\":\"Camera\",\"price\":250,\"category\":\"photo\"}", - "tags": [["d", "product-123"]] -} -``` +- **Use `content` for** large freeform text or existing industry-standard JSON formats (GeoJSON, FHIR, Tiled). Kind 0 is the one exception where structured JSON goes in `content`. +- **Use tags for** queryable metadata and structured data — anything you might filter on. +- **Empty content is fine.** `content: ""` is idiomatic for tag-only events. +- If you need to filter by a field, it **must** be a tag — relays don't index content. ### NIP.md -The file `NIP.md` is used by this project to define a custom Nostr protocol document. If the file doesn't exist, it means this project doesn't have any custom kinds associated with it. +`NIP.md` documents any custom kinds/schemas this project defines. If the file doesn't exist, this project has no custom kinds. **Whenever you generate a new kind or change a custom schema, create or update `NIP.md`.** -Whenever new kinds are generated, the `NIP.md` file in the project must be created or updated to document the custom event schema. Whenever the schema of one of these custom events changes, `NIP.md` must also be updated accordingly. +### Nostr Security Model + +**CRITICAL:** Nostr private keys (`nsec`) are stored **in plaintext in `localStorage`**. Any JavaScript running on the origin can steal them. A single XSS = permanent, unrecoverable key theft across every Nostr client the user ever touches. **Treat XSS mitigation as the top-priority security concern.** + +- **Never** use `dangerouslySetInnerHTML`, `innerHTML`, or `document.write` with event data, URL params, or other untrusted strings. +- **CSP is defense-in-depth**, not primary defense. `index.html` ships a restrictive CSP (`script-src 'self'`, `default-src 'none'`). Never relax it with `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, or wildcard sources. +- **Sanitize every event-sourced URL** (`sanitizeUrl()` — https-only allowlist) before using it as `href`, `src`, iframe `src`, or CSS `url()`. +- **Sanitize every event-sourced string interpolated into CSS**. A malicious `font-family` or `url()` value can break out of the CSS context and inject rules. + +Beyond XSS, Nostr is permissionless — signatures prove authorship, not trustworthiness. Filter by `authors` whenever trust is implied: + +- **Admin/moderator/owner queries** — filter by trusted pubkeys. +- **Addressable events (kinds 30000–39999)** and **user-owned replaceable events** — filter by `authors`; the `d` tag alone is not a trust boundary. +- **Routes for addressable/replaceable events** — include the author in the URL (e.g. `/article/:npub/:slug`) so the filter can constrain on author. +- **Public UGC** (kind 1 notes, reactions, public feeds, discovery) — author filtering NOT required. + +```ts +// ❌ Anyone can spoof this event +nostr.query([{ kinds: [30078], '#d': ['app-organizers'], limit: 1 }]); +// ✅ Only trust admin authors +nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['app-organizers'], limit: 1 }]); +``` + +For the full threat model — CSP walkthrough, `sanitizeUrl` / `sanitizeCssString` implementations, NIP-72 community moderation, and the pre-merge checklist — load the **`nostr-security`** skill. ### The `useNostr` Hook -The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively. - -```typescript +```ts import { useNostr } from '@nostrify/react'; function useCustomHook() { const { nostr } = useNostr(); - - // ... + // nostr.query(filters) / nostr.event(event) / nostr.req(filters) } ``` -### Connecting to Multiple Nostr Relays +By default `nostr` uses the app's connection pool (reads from one relay, publishes to all configured). For targeted single-relay or relay-group calls, load the **`nostr-relay-pools`** skill. -By default, the `nostr` object from `useNostr` uses a pool configuration that reads data from 1 relay and publishes to all configured relays. However, you can connect to specific relays or groups of relays for more granular control: +### Querying with TanStack Query -#### Single Relay Connection - -To read and publish from one specific relay, use `nostr.relay()` with a WebSocket URL: - -```typescript -import { useNostr } from '@nostrify/react'; - -function useSpecificRelay() { - const { nostr } = useNostr(); - - // Connect to a specific relay - const relay = nostr.relay('wss://relay.damus.io'); - - // Query from this specific relay only - const events = await relay.query([{ kinds: [1], limit: 20 }], { signal }); - - // Publish to this specific relay only - await relay.event({ kind: 1, content: 'Hello from specific relay!' }); -} -``` - -#### Multiple Relay Group - -To read and publish from a specific set of relays, use `nostr.group()` with an array of relay URLs: - -```typescript -import { useNostr } from '@nostrify/react'; - -function useRelayGroup() { - const { nostr } = useNostr(); - - // Create a group of specific relays - const relayGroup = nostr.group([ - 'wss://relay.damus.io', - 'wss://relay.nostr.band', - 'wss://nos.lol' - ]); - - // Query from all relays in the group - const events = await relayGroup.query([{ kinds: [1], limit: 20 }], { signal }); - - // Publish to all relays in the group - await relayGroup.event({ kind: 1, content: 'Hello from relay group!' }); -} -``` - -#### API Consistency - -Both `relay` and `group` objects have the same API as the main `nostr` object, including: - -- `.query()` - Query events with filters -- `.req()` - Create subscriptions -- `.event()` - Publish events -- All other Nostr protocol methods - -#### Use Cases - -**Single Relay (`nostr.relay()`):** -- Testing specific relay behavior -- Querying relay-specific content -- Debugging connectivity issues -- Working with specialized relays - -**Relay Group (`nostr.group()`):** -- Querying from trusted relay sets -- Publishing to specific communities -- Load balancing across relay subsets -- Geographic relay optimization - -**Default Pool (`nostr`):** -- General application queries -- Maximum reach for publishing -- Default user experience -- Simplified relay management - -### Query Nostr Data with `useNostr` and Tanstack Query - -When querying Nostr, the best practice is to create custom hooks that combine `useNostr` and `useQuery` to get the required data. - -```typescript -import { useNostr } from '@nostrify/react'; -import { useQuery } from '@tanstack/query'; +Combine `useNostr` with `useQuery` in custom hooks: +```ts function usePosts() { const { nostr } = useNostr(); - return useQuery({ queryKey: ['posts'], - queryFn: async (c) => { - const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]); - const events = await nostr.query([{ kinds: [1], limit: 20 }], { signal }); - return events; // these events could be transformed into another format - }, + queryFn: async (c) => nostr.query([{ kinds: [1], limit: 15 }], { signal: c.signal }), }); } ``` -### Efficient Query Design +**Efficient query design** — minimize round-trips: -**Critical**: Always minimize the number of separate queries to avoid rate limiting and improve performance. Combine related queries whenever possible. +- **Combine kinds** in one filter: `{ kinds: [1, 6, 16], '#e': [eventId] }` and split by kind in JS. Don't run three parallel queries for repost variants. +- **Use multiple filter objects** in one query when different tag filters are needed. +- **Raise `limit`** when combining so you still get enough of each kind. +- Each query costs relay capacity and may count against rate limits. -**✅ Efficient - Single query with multiple kinds:** -```typescript -// Query multiple event types in one request -const events = await nostr.query([ - { - kinds: [1, 6, 16], // All repost kinds in one query - '#e': [eventId], - limit: 150, - } -], { signal }); +**Event validation** — for kinds with required tags or strict schemas, filter query results through a validator: -// Separate by type in JavaScript -const notes = events.filter((e) => e.kind === 1); -const reposts = events.filter((e) => e.kind === 6); -const genericReposts = events.filter((e) => e.kind === 16); -``` - -**❌ Inefficient - Multiple separate queries:** -```typescript -// This creates unnecessary load and can trigger rate limiting -const [notes, reposts, genericReposts] = await Promise.all([ - nostr.query([{ kinds: [1], '#e': [eventId] }], { signal }), - nostr.query([{ kinds: [6], '#e': [eventId] }], { signal }), - nostr.query([{ kinds: [16], '#e': [eventId] }], { signal }), -]); -``` - -**Query Optimization Guidelines:** -1. **Combine kinds**: Use `kinds: [1, 6, 16]` instead of separate queries -2. **Use multiple filters**: When you need different tag filters, use multiple filter objects in a single query -3. **Adjust limits**: When combining queries, increase the limit appropriately -4. **Filter in JavaScript**: Separate event types after receiving results rather than making multiple requests -5. **Consider relay capacity**: Each query consumes relay resources and may count against rate limits - -The data may be transformed into a more appropriate format if needed, and multiple calls to `nostr.query()` may be made in a single queryFn. - -### Event Validation - -When querying events, if the event kind being returned has required tags or required JSON fields in the content, the events should be filtered through a validator function. This is not generally needed for kinds such as 1, where all tags are optional and the content is freeform text, but is especially useful for custom kinds as well as kinds with strict requirements. - -```typescript -// Example validator function for NIP-52 calendar events -function validateCalendarEvent(event: NostrEvent): boolean { - // Check if it's a calendar event kind +```ts +function isValidCalendarEvent(event: NostrEvent): boolean { if (![31922, 31923].includes(event.kind)) return false; - - // Check for required tags according to NIP-52 - const d = event.tags.find(([name]) => name === 'd')?.[1]; - const title = event.tags.find(([name]) => name === 'title')?.[1]; - const start = event.tags.find(([name]) => name === 'start')?.[1]; - - // All calendar events require 'd', 'title', and 'start' tags - if (!d || !title || !start) return false; - - // Additional validation for date-based events (kind 31922) - if (event.kind === 31922) { - // start tag should be in YYYY-MM-DD format for date-based events - const dateRegex = /^\d{4}-\d{2}-\d{2}$/; - if (!dateRegex.test(start)) return false; - } - - // Additional validation for time-based events (kind 31923) - if (event.kind === 31923) { - // start tag should be a unix timestamp for time-based events - const timestamp = parseInt(start); - if (isNaN(timestamp) || timestamp <= 0) return false; - } - - return true; + const d = event.tags.find(([n]) => n === 'd')?.[1]; + const title = event.tags.find(([n]) => n === 'title')?.[1]; + const start = event.tags.find(([n]) => n === 'start')?.[1]; + return Boolean(d && title && start); } -function useCalendarEvents() { - const { nostr } = useNostr(); - - return useQuery({ - queryKey: ['calendar-events'], - queryFn: async (c) => { - const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]); - const events = await nostr.query([{ kinds: [31922, 31923], limit: 20 }], { signal }); - - // Filter events through validator to ensure they meet NIP-52 requirements - return events.filter(validateCalendarEvent); - }, - }); -} +const events = (await nostr.query([{ kinds: [31922, 31923], limit: 15 }])) + .filter(isValidCalendarEvent); ``` +Validation is optional for loose kinds (kind 1), but strongly recommended for custom kinds and kinds with required tags. + ### The `useAuthor` Hook -To display profile data for a user by their Nostr pubkey (such as an event author), use the `useAuthor` hook. +Fetch profile metadata (kind 0) for a pubkey: ```tsx import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { useAuthor } from '@/hooks/useAuthor'; -import { genUserName } from '@/lib/genUserName'; function Post({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const metadata: NostrMetadata | undefined = author.data?.metadata; - const displayName = metadata?.name ?? genUserName(event.pubkey); + const displayName = metadata?.name ?? 'Anonymous'; const profileImage = metadata?.picture; - - // ...render elements with this data + // ... } ``` -### `NostrMetadata` type - -```ts -/** Kind 0 metadata. */ -interface NostrMetadata { - /** A short description of the user. */ - about?: string; - /** A URL to a wide (~1024x768) picture to be optionally displayed in the background of a profile screen. */ - banner?: string; - /** A boolean to clarify that the content is entirely or partially the result of automation, such as with chatbots or newsfeeds. */ - bot?: boolean; - /** An alternative, bigger name with richer characters than `name`. `name` should always be set regardless of the presence of `display_name` in the metadata. */ - display_name?: string; - /** A bech32 lightning address according to NIP-57 and LNURL specifications. */ - lud06?: string; - /** An email-like lightning address according to NIP-57 and LNURL specifications. */ - lud16?: string; - /** A short name to be displayed for the user. */ - name?: string; - /** An email-like Nostr address according to NIP-05. */ - nip05?: string; - /** A URL to the user's avatar. */ - picture?: string; - /** A web URL related in any way to the event author. */ - website?: string; -} -``` +The `NostrMetadata` type (from `@nostrify/nostrify`) covers the standard kind-0 fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`, `nip05`, `lud06`, `lud16`, `bot`. Read the type definition from the package if you need the exact field list. ### The `useNostrPublish` Hook -To publish events, use the `useNostrPublish` hook in this project. This hook automatically adds a "client" tag to published events. +Publishes events (auto-adds a `client` tag). Always guard with `useCurrentUser`: ```tsx -import { useState } from 'react'; - -import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; export function MyComponent() { - const [ data, setData] = useState>({}); - const { user } = useCurrentUser(); const { mutate: createEvent } = useNostrPublish(); - const handleSubmit = () => { - createEvent({ kind: 1, content: data.content }); - }; - - if (!user) { - return You must be logged in to use this form.; - } + if (!user) return You must be logged in.; return ( -
- {/* ...some input fields */} -
+ ); } ``` -The `useCurrentUser` hook should be used to ensure that the user is logged in before they are able to publish Nostr events. - ### Nostr Login -To enable login with Nostr, simply use the `LoginArea` component already included in this project. +Use the `LoginArea` component (already in the project). It renders a single "Join" button when logged out (opens an `AuthDialog` supporting signup, extension, nsec, and remote signer) and becomes an account switcher when logged in. **Do not wrap it in conditional logic.** ```tsx -import { LoginArea } from "@/components/auth/LoginArea"; +import { LoginArea } from '@/components/auth/LoginArea'; -function MyComponent() { - return ( -
- {/* other components ... */} - - -
- ); -} + ``` -The `LoginArea` component handles all the login-related UI and interactions, including displaying login dialogs, sign up functionality, and switching between accounts. It should not be wrapped in any conditional logic. +`LoginArea` is inline-flex by default. Pass `flex` or `w-full` to expand it; otherwise set a sensible `max-w-*`. -`LoginArea` displays both "Log in" and "Sign Up" buttons when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like `flex` (to make it a block element) or `w-full`. If it is left as inline-flex, it's recommended to set a max width. +**Social apps should include a profile/account menu in the main navigation** for access to settings, profile editing, and logout — don't only show `LoginArea` in logged-out states. -**Important**: Social applications should include a profile menu button in the main interface (typically in headers/navigation) to provide access to account settings, profile editing, and logout functionality. Don't only show `LoginArea` in logged-out states. +### NIP-19 Identifiers -### `npub`, `naddr`, and other Nostr addresses +Nostr uses bech32-encoded identifiers (`npub1`, `nprofile1`, `note1`, `nevent1`, `naddr1`, `nsec1`). **All NIP-19 identifiers are routed at the URL root (`/:nip19`)**, handled by `src/pages/NIP19Page.tsx` — never nest them under `/note/`, `/profile/`, etc. -Nostr defines a set of bech32-encoded identifiers in NIP-19. Their prefixes and purposes: - -- `npub1`: **public keys** - Just the 32-byte public key, no additional metadata -- `nsec1`: **private keys** - Secret keys (should never be displayed publicly) -- `note1`: **event IDs** - Just the 32-byte event ID (hex), no additional metadata -- `nevent1`: **event pointers** - Event ID plus optional relay hints and author pubkey -- `nprofile1`: **profile pointers** - Public key plus optional relay hints and petname -- `naddr1`: **addressable event coordinates** - For parameterized replaceable events (kind 30000-39999) -- `nrelay1`: **relay references** - Relay URLs (deprecated) - -#### Key Differences Between Similar Identifiers - -**`note1` vs `nevent1`:** -- `note1`: Contains only the event ID (32 bytes) - specifically for kind:1 events (Short Text Notes) as defined in NIP-10 -- `nevent1`: Contains event ID plus optional relay hints and author pubkey - for any event kind -- Use `note1` for simple references to text notes and threads -- Use `nevent1` when you need to include relay hints or author context for any event type - -**`npub1` vs `nprofile1`:** -- `npub1`: Contains only the public key (32 bytes) -- `nprofile1`: Contains public key plus optional relay hints and petname -- Use `npub1` for simple user references -- Use `nprofile1` when you need to include relay hints or display name context - -#### NIP-19 Routing Implementation - -**Critical**: NIP-19 identifiers should be handled at the **root level** of URLs (e.g., `/note1...`, `/npub1...`, `/naddr1...`), NOT nested under paths like `/note/note1...` or `/profile/npub1...`. - -This project includes a boilerplate `NIP19Page` component that provides the foundation for handling all NIP-19 identifier types at the root level. The component is configured in the routing system and ready for AI agents to populate with specific functionality. - -**How it works:** - -1. **Root-Level Route**: The route `/:nip19` in `AppRouter.tsx` catches all NIP-19 identifiers -2. **Automatic Decoding**: The `NIP19Page` component automatically decodes the identifier using `nip19.decode()` -3. **Type-Specific Sections**: Different sections are rendered based on the identifier type: - - `npub1`/`nprofile1`: Profile section with placeholder for profile view - - `note1`: Note section with placeholder for kind:1 text note view - - `nevent1`: Event section with placeholder for any event type view - - `naddr1`: Addressable event section with placeholder for articles, marketplace items, etc. -4. **Error Handling**: Invalid, vacant, or unsupported identifiers show 404 NotFound page -5. **Ready for Population**: Each section includes comments indicating where AI agents should implement specific functionality - -**Example URLs that work automatically:** -- `/npub1abc123...` - User profile (needs implementation) -- `/note1def456...` - Kind:1 text note (needs implementation) -- `/nevent1ghi789...` - Any event with relay hints (needs implementation) -- `/naddr1jkl012...` - Addressable event (needs implementation) - -**Features included:** -- Basic NIP-19 identifier decoding and routing -- Type-specific sections for different identifier types -- Error handling for invalid identifiers -- Responsive container structure -- Comments indicating where to implement specific views - -**Error handling:** -- Invalid NIP-19 format → 404 NotFound -- Unsupported identifier types (like `nsec1`) → 404 NotFound -- Empty or missing identifiers → 404 NotFound - -To implement NIP-19 routing in your Nostr application: - -1. **The NIP19Page boilerplate is already created** - populate sections with specific functionality -2. **The route is already configured** in `AppRouter.tsx` -3. **Error handling is built-in** - all edge cases show appropriate 404 responses -4. **Add specific components** for profile views, event displays, etc. as needed - -#### Event Type Distinctions - -**`note1` identifiers** are specifically for **kind:1 events** (Short Text Notes) as defined in NIP-10: "Text Notes and Threads". These are the basic social media posts in Nostr. - -**`nevent1` identifiers** can reference any event kind and include additional metadata like relay hints and author pubkey. Use `nevent1` when: -- The event is not a kind:1 text note -- You need to include relay hints for better discoverability -- You want to include author context - -#### Use in Filters - -The base Nostr protocol uses hex string identifiers when filtering by event IDs and pubkeys. Nostr filters only accept hex strings. +**Filters only accept hex.** Always decode before querying: ```ts -// ❌ Wrong: naddr is not decoded -const events = await nostr.query( - [{ ids: [naddr] }], - { signal } -); -``` - -Corrected example: - -```ts -// Import nip19 from nostr-tools import { nip19 } from 'nostr-tools'; -// Decode a NIP-19 identifier const decoded = nip19.decode(value); +if (decoded.type !== 'naddr') throw new Error('Unsupported identifier'); +const { kind, pubkey, identifier } = decoded.data; -// Optional: guard certain types (depending on the use-case) -if (decoded.type !== 'naddr') { - throw new Error('Unsupported Nostr identifier'); -} - -// Get the addr object -const naddr = decoded.data; - -// ✅ Correct: naddr is expanded into the correct filter -const events = await nostr.query( - [{ - kinds: [naddr.kind], - authors: [naddr.pubkey], - '#d': [naddr.identifier], - }], - { signal } -); +nostr.query([{ + kinds: [kind], + authors: [pubkey], // critical for addressable events + '#d': [identifier], +}]); ``` -#### Implementation Guidelines +Never treat `nsec1` or unknown prefixes as anything but a 404. -1. **Always decode NIP-19 identifiers** before using them in queries -2. **Use the appropriate identifier type** based on your needs: - - Use `note1` for kind:1 text notes specifically - - Use `nevent1` when including relay hints or for non-kind:1 events - - Use `naddr1` for addressable events (always includes author pubkey for security) -3. **Handle different identifier types** appropriately: - - `npub1`/`nprofile1`: Display user profiles - - `note1`: Display kind:1 text notes specifically - - `nevent1`: Display any event with optional relay context - - `naddr1`: Display addressable events (articles, marketplace items, etc.) -4. **Security considerations**: Always use `naddr1` for addressable events instead of just the `d` tag value, as `naddr1` contains the author pubkey needed to create secure filters -5. **Error handling**: Gracefully handle invalid or unsupported NIP-19 identifiers with 404 responses +**For full details** (identifier-type comparison, populating `NIP19Page`, building NIP-19 links, security patterns), load the **`nip19-routing`** skill. -### Nostr Edit Profile +### File Uploads, Encryption, Multi-Relay -To include an Edit Profile form, place the `EditProfileForm` component in the project: +These are specialized workflows — load the matching skill when needed: -```tsx -import { EditProfileForm } from "@/components/EditProfileForm"; - -function EditProfilePage() { - return ( -
- {/* you may want to wrap this in a layout or include other components depending on the project ... */} - - -
- ); -} -``` - -The `EditProfileForm` component displays just the form. It requires no props, and will "just work" automatically. - -### Direct Messaging (NIP-04 & NIP-17) - -The project includes a complete direct messaging system with real-time updates, encrypted storage, and support for both NIP-04 (legacy) and NIP-17 (modern private messaging) protocols. **The system is disabled by default** - enable it by passing `enabled: true` in the `DMProvider` config. - -For complete implementation guide including: -- Setup and configuration -- Sending messages and file attachments -- Using the `DMMessagingInterface` component -- Building custom messaging UIs -- Protocol comparison (NIP-04 vs NIP-17) -- Advanced features and architecture - -See **`docs/NOSTR_DIRECT_MESSAGES.md`** - -### Uploading Files on Nostr - -Use the `useUploadFile` hook to upload files. This hook uses Blossom servers for file storage and returns NIP-94 compatible tags. - -```tsx -import { useUploadFile } from "@/hooks/useUploadFile"; - -function MyComponent() { - const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); - - const handleUpload = async (file: File) => { - try { - // Provides an array of NIP-94 compatible tags - // The first tag in the array contains the URL - const [[_, url]] = await uploadFile(file); - // ...use the url - } catch (error) { - // ...handle errors - } - }; - - // ...rest of component -} -``` - -To attach files to kind 1 events, each file's URL should be appended to the event's `content`, and an `imeta` tag should be added for each file. For kind 0 events, the URL by itself can be used in relevant fields of the JSON content. - -### Nostr Encryption and Decryption - -The logged-in user has a `signer` object (matching the NIP-07 signer interface) that can be used for encryption and decryption. The signer's nip44 methods handle all cryptographic operations internally, including key derivation and conversation key management, so you never need direct access to private keys. Always use the signer interface for encryption rather than requesting private keys from users, as this maintains security and follows best practices. - -```ts -// Get the current user -const { user } = useCurrentUser(); - -// Optional guard to check that nip44 is available -if (!user.signer.nip44) { - throw new Error("Please upgrade your signer extension to a version that supports NIP-44 encryption"); -} - -// Encrypt message to self -const encrypted = await user.signer.nip44.encrypt(user.pubkey, "hello world"); -// Decrypt message to self -const decrypted = await user.signer.nip44.decrypt(user.pubkey, encrypted) // "hello world" -``` - -### Rendering Rich Text Content - -Nostr text notes (kind 1, 11, and 1111) have a plaintext `content` field that may contain URLs, hashtags, and Nostr URIs. These events should render their content using the `NoteContent` component: - -```tsx -import { NoteContent } from "@/components/NoteContent"; - -export function Post(/* ...props */) { - // ... - - return ( - -
- -
-
- ); -} -``` +- **`file-uploads`** — `useUploadFile` + Blossom + NIP-94 `imeta` tags. +- **`nostr-encryption`** — NIP-44 / NIP-04 via the user's signer (DMs, gift wraps, private content). +- **`nostr-relay-pools`** — `nostr.relay(url)` / `nostr.group([urls])` for targeted queries. ## App Configuration -The project includes an `AppProvider` that manages global application state including theme and NIP-65 relay configuration. The default configuration includes: +The `AppProvider` manages global state (theme + NIP-65 relay list), persisted to local storage. -```typescript +```ts const defaultConfig: AppConfig = { - theme: "light", + theme: 'light', relayMetadata: { relays: [ { url: 'wss://relay.ditto.pub', read: true, write: true }, - { url: 'wss://relay.nostr.band', read: true, write: true }, - { url: 'wss://relay.damus.io', read: true, write: true }, + { url: 'wss://relay.dreamith.to', read: true, write: true }, + { url: 'wss://relay.primal.net', read: false, write: true }, + { url: 'wss://nos.lol', read: false, write: true }, ], updatedAt: 0, }, }; ``` -The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations. - ### Relay Management -The project includes a complete NIP-65 relay management system: - -- **RelayListManager**: Component for managing multiple relays with read/write permissions -- **NostrSync**: Automatically syncs user's NIP-65 relay list when they log in -- **Automatic Publishing**: Changes to relay configuration are automatically published as NIP-65 events when the user is logged in - -Use the `RelayListManager` component to provide relay management interfaces: - -```tsx -import { RelayListManager } from '@/components/RelayListManager'; - -function SettingsPage() { - return ( -
-

Relay Settings

- -
- ); -} -``` +- **`NostrSync`** auto-loads the user's NIP-65 relay list on login and writes it into `AppContext`. +- **Automatic publishing** — updating the relay config publishes a new kind 10002 event when the user is logged in. +- A drop-in settings UI (`RelayListManager`) is available as the **`relay-management`** skill. ## Routing -The project uses React Router with a centralized routing configuration in `AppRouter.tsx`. To add new routes: +Routes live in `AppRouter.tsx`. To add one: -1. Create your page component in `/src/pages/` -2. Import it in `AppRouter.tsx` -3. Add the route above the catch-all `*` route: +1. Create the page component in `src/pages/`. +2. Import it in `AppRouter.tsx`. +3. Add the route **above** the catch-all `*` route: ```tsx } /> ``` -The router includes automatic scroll-to-top functionality and a 404 NotFound page for unmatched routes. +The router provides automatic scroll-to-top on navigation and a 404 `NotFound` page. The `/:nip19` route is already wired (see the `nip19-routing` skill). -## Development Practices +## Design Standards -- Uses React Query for data fetching and caching -- Follows shadcn/ui component patterns -- Implements Path Aliases with `@/` prefix for cleaner imports -- Uses Vite for fast development and production builds -- Component-based architecture with React hooks -- Default connection to one Nostr relay for best performance -- Comprehensive provider setup with NostrLoginProvider, QueryClientProvider, and custom AppProvider -- **Never use the `any` type**: Always use proper TypeScript types for type safety +Designs should be polished and production-ready. Concrete rules: -## Loading States +- **Responsive** down to ~360px; test mobile, tablet, desktop. +- **WCAG 2.1 AA**: ≥ 4.5:1 contrast for body text, ≥ 3:1 for large text and UI elements. Full keyboard nav, ARIA labels, visible `focus-visible` rings. +- **8px grid** for spacing (Tailwind's 4-based scale). Don't sprinkle `p-[13px]`-style one-offs. +- **Typography hierarchy**: ≥ 18px body, ≥ 40px primary headlines. Prefer a modern sans (e.g. Inter) for UI and pair a display/serif for headings when personality is needed. +- **Depth**: soft shadows, gentle gradients, rounded corners (`rounded-lg` / `rounded-xl`). Avoid heavy drop shadows. +- **Motion**: lightweight, purposeful (hover, scroll reveals, transitions). Respect `prefers-reduced-motion` with Tailwind's `motion-safe:` / `motion-reduce:` variants. +- **Reusable components**: consistent variants and feedback states (`hover`, `focus-visible`, `active`, `disabled`, `aria-invalid`). Use `cn()` for conditional classes and `class-variance-authority` for variants (copy an existing `ui/` component as a template). +- **Custom over generic**: avoid template-looking headers — combine layered visuals, subtle motion, and brand colors. Generate custom images with available tools before reaching for stock. -**Use skeleton loading** for structured content (feeds, profiles, forms). **Use spinners** only for buttons or short operations. +### Loading and Empty States + +**Use skeletons** for structured content (feeds, profiles, forms). **Use spinners** only for buttons or short operations. ```tsx -// Skeleton example matching component structure
@@ -921,221 +312,38 @@ The router includes automatic scroll-to-top functionality and a 404 NotFound pag ``` -### Empty States and No Content Found - -When no content is found (empty search results, no data available, etc.), display a minimalist empty state with helpful messaging. The application uses NIP-65 relay management, so users can manage their relays through the settings or relay management interface. +For empty results, show a minimalist empty state in a `border-dashed` card: ```tsx -import { Card, CardContent } from '@/components/ui/card'; - -// Empty state example -
- - -
-

- No results found. Try checking your relay connections or wait a moment for content to load. -

-
-
-
-
+ + +

+ No results found. Try checking your relay connections or wait a moment for content to load. +

+
+
``` -## CRITICAL Design Standards +For font installation, color-scheme changes, light/dark theming, or the `isolate` + negative-z-index gotcha, load the **`theming`** skill. -- Create breathtaking, immersive designs that feel like bespoke masterpieces, rivaling the polish of Apple, Stripe, or luxury brands -- Designs must be production-ready, fully featured, with no placeholders unless explicitly requested, ensuring every element serves a functional and aesthetic purpose -- Avoid generic or templated aesthetics at all costs; every design must have a unique, brand-specific visual signature that feels custom-crafted -- Headers must be dynamic, immersive, and storytelling-driven, using layered visuals, motion, and symbolic elements to reflect the brand’s identity—never use simple “icon and text” combos -- Incorporate purposeful, lightweight animations for scroll reveals, micro-interactions (e.g., hover, click, transitions), and section transitions to create a sense of delight and fluidity +## Writing Tests vs. Running Tests -### Design Principles +**Running the existing test script — always do it.** After any code change, run the project's test/validation script. **Your task is not complete until it passes.** The script typically covers TypeScript compilation, ESLint, and existing tests. -- Achieve Apple-level refinement with meticulous attention to detail, ensuring designs evoke strong emotions (e.g., wonder, inspiration, energy) through color, motion, and composition -- Deliver fully functional interactive components with intuitive feedback states, ensuring every element has a clear purpose and enhances user engagement -- **Generate custom images liberally** when image generation tools are available - this is ALWAYS preferred over stock photography for creating unique, brand-specific visuals that perfectly match the design intent -- Ensure designs feel alive and modern with dynamic elements like gradients, glows, or parallax effects, avoiding static or flat aesthetics -- Before finalizing, ask: "Would this design make Apple or Stripe designers pause and take notice?" If not, iterate until it does - -### Avoid Generic Design - -- No basic layouts (e.g., text-on-left, image-on-right) without significant custom polish, such as dynamic backgrounds, layered visuals, or interactive elements -- No simplistic headers; they must be immersive, animated, and reflective of the brand’s core identity and mission -- No designs that could be mistaken for free templates or overused patterns; every element must feel intentional and tailored - -### Interaction Patterns - -- Use progressive disclosure for complex forms or content to guide users intuitively and reduce cognitive load -- Incorporate contextual menus, smart tooltips, and visual cues to enhance navigation and usability -- Implement drag-and-drop, hover effects, and transitions with clear, dynamic visual feedback to elevate the user experience -- Support power users with keyboard shortcuts, ARIA labels, and focus states for accessibility and efficiency -- Add subtle parallax effects or scroll-triggered animations to create depth and engagement without overwhelming the user - -### Technical Requirements - -- Curated color FRpalette (3-5 evocative colors + neutrals) that aligns with the brand’s emotional tone and creates a memorable impact -- Ensure a minimum 4.5:1 contrast ratio for all text and interactive elements to meet accessibility standards -- Use expressive, readable fonts (18px+ for body text, 40px+ for headlines) with a clear hierarchy; pair a modern sans-serif (e.g., Inter) with an elegant serif (e.g., Playfair Display) for personality -- Design for full responsiveness, ensuring flawless performance and aesthetics across all screen sizes (mobile, tablet, desktop) -- Adhere to WCAG 2.1 AA guidelines, including keyboard navigation, screen reader support, and reduced motion options -- Follow an 8px grid system for consistent spacing, padding, and alignment to ensure visual harmony -- Add depth with subtle shadows, gradients, glows, and rounded corners (e.g., 16px radius) to create a polished, modern aesthetic -- Optimize animations and interactions to be lightweight and performant, ensuring smooth experiences across devices - -### Components - -- Design reusable, modular components with consistent styling, behavior, and feedback states (e.g., hover, active, focus, error) -- Include purposeful animations (e.g., scale-up on hover, fade-in on scroll) to guide attention and enhance interactivity without distraction -- Ensure full accessibility support with keyboard navigation, ARIA labels, and visible focus states (e.g., a glowing outline in an accent color) -- Use custom icons or illustrations for components to reinforce the brand’s visual identity - -### Adding Fonts - -To add custom fonts, follow these steps: - -1. **Install a font package** using npm: - - **Any Google Font can be installed** using the @fontsource packages. Examples: - - For Inter Variable: `@fontsource-variable/inter` - - For Roboto: `@fontsource/roboto` - - For Outfit Variable: `@fontsource-variable/outfit` - - For Poppins: `@fontsource/poppins` - - For Open Sans: `@fontsource/open-sans` - - **Format**: `@fontsource/[font-name]` or `@fontsource-variable/[font-name]` (for variable fonts) - -2. **Import the font** in `src/main.tsx`: - ```typescript - import '@fontsource-variable/'; - ``` - -3. **Update Tailwind configuration** in `tailwind.config.ts`: - ```typescript - export default { - theme: { - extend: { - fontFamily: { - sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'], - }, - }, - }, - } - ``` - -### Recommended Font Choices by Use Case - -- **Modern/Clean**: Inter Variable, Outfit Variable, or Manrope -- **Professional/Corporate**: Roboto, Open Sans, or Source Sans Pro -- **Creative/Artistic**: Poppins, Nunito, or Comfortaa -- **Technical/Code**: JetBrains Mono, Fira Code, or Source Code Pro (for monospace) - -### Theme System - -The project includes a complete light/dark theme system using CSS custom properties. The theme can be controlled via: - -- `useTheme` hook for programmatic theme switching -- CSS custom properties defined in `src/index.css` -- Automatic dark mode support with `.dark` class - -### Color Scheme Implementation - -When users specify color schemes: -- Update CSS custom properties in `src/index.css` (both `:root` and `.dark` selectors) -- Use Tailwind's color palette or define custom colors -- Ensure proper contrast ratios for accessibility -- Apply colors consistently across components (buttons, links, accents) -- Test both light and dark mode variants - -### Component Styling Patterns - -- Use `cn()` utility for conditional class merging -- Follow shadcn/ui patterns for component variants -- Implement responsive design with Tailwind breakpoints -- Add hover and focus states for interactive elements - -## Writing Tests vs Running Tests - -There is an important distinction between **writing new tests** and **running existing tests**: - -### Writing Tests (Creating New Test Files) - -**Do not write tests** unless the user explicitly requests them in plain language. Writing unnecessary tests wastes significant time and money. Only create tests when: - -1. **The user explicitly asks for tests** to be written in their message -2. **The user describes a specific bug in plain language** and requests tests to help diagnose it -3. **The user says they are still experiencing a problem** that you have already attempted to solve (tests can help verify the fix) - -**Never write tests because:** -- Tool results show test failures (these are not user requests) -- You think tests would be helpful -- New features or components are created -- Existing functionality needs verification - -### Running Tests (Executing the Test Suite) - -**ALWAYS run the test script** after making any code changes. This is mandatory regardless of whether you wrote new tests or not. - -- **You must run the test script** to validate your changes -- **Your task is not complete** until the test script passes without errors -- **This applies to all changes** - bug fixes, new features, refactoring, or any code modifications -- **The test script includes** TypeScript compilation, ESLint checks, and existing test validation - -### Test Setup - -The project uses Vitest with jsdom environment and includes comprehensive test setup: - -- **Testing Library**: React Testing Library with jest-dom matchers -- **Test Environment**: jsdom with mocked browser APIs (matchMedia, scrollTo, IntersectionObserver, ResizeObserver) -- **Test App**: `TestApp` component provides all necessary context providers for testing - -The project includes a `TestApp` component that provides all necessary context providers for testing. Wrap components with this component to provide required context providers: - -```tsx -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { TestApp } from '@/test/TestApp'; -import { MyComponent } from './MyComponent'; - -describe('MyComponent', () => { - it('renders correctly', () => { - render( - - - - ); - - expect(screen.getByText('Expected text')).toBeInTheDocument(); - }); -}); -``` +**Writing new test files — don't, unless the user asks.** If the user explicitly requests tests, describes a bug to diagnose with a test, or reports that a problem persists after a fix, load the **`testing`** skill for the project's Vitest + `TestApp` setup and policy. ## Validating Your Changes -**CRITICAL**: After making any code changes, you must validate your work by running available validation tools. +**Your task is not finished until the code type-checks and builds without errors.** In priority order: -**Your task is not considered finished until the code successfully type-checks and builds without errors.** - -### Validation Priority Order - -Run available tools in this priority order: - -1. **Type Checking** (Required): Ensure TypeScript compilation succeeds -2. **Building/Compilation** (Required): Verify the project builds successfully -3. **Linting** (Recommended): Check code style and catch potential issues -4. **Tests** (If Available): Run existing test suite -5. **Git Commit** (Required): Create a commit with your changes when finished - -**Minimum Requirements:** -- Code must type-check without errors -- Code must build/compile successfully -- Fix any critical linting errors that would break functionality -- Create a git commit when your changes are complete - -The validation ensures code quality and catches errors before deployment, regardless of the development environment. +1. **Type check** (required) +2. **Build/compile** (required) +3. **Lint** (recommended; fix anything critical) +4. **Run tests** (if available) +5. **Git commit** (required) ### Using Git -If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files. +Use `git status` / `git diff` to review changes and `git log` to learn project conventions. If you make a mistake, `git checkout` restores files. -When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes. \ No newline at end of file +**Always commit when you are finished.** Non-negotiable — every completed task ends with a commit. Don't wait for the user to ask. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..24ae44b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,521 @@ +# PLAN.md — Nostr Layer Systems "Web OS" + +## 1. Vision + +Die App ist kein klassisches Website-Layout mit Navigation und Unterseiten, sondern +ein **Desktop-Betriebssystem im Browser**. Jede frühere „Page" wird zu einer **App**, +die als Fenster geöffnet, verschoben, skaliert, minimiert, maximiert und geschlossen +werden kann. + +Optisch: **macOS-Grundstruktur, PostHog-Ästhetik.** + +- macOS liefert die *Mechanik*: Menüleiste oben, Fenster mit Titelleiste, Traffic-Light-Buttons, + Fokus/Z-Order. +- [posthog.com](https://posthog.com) liefert die *Optik*: hell, ruhig, viel Weißraum, + flache Flächen statt Glaseffekt-Orgie, kräftige aber sparsame Akzentfarbe, + klare Typo-Hierarchie, dezente 1px-Rahmen, leicht verspielte Details ohne Skeuomorphismus. + +**Nicht-Ziel:** kein Fake-macOS-Klon mit Apple-Icons, keine Glassmorphism-Überladung, +keine Emulator-Spielerei. Das OS ist eine Metapher für Multitasking, nicht Selbstzweck. + +--- + +## 2. Ausgangslage (Stand heute) + +Das Repo ist der unveränderte mkstack-Startpunkt: + +- `src/AppRouter.tsx` — drei Routen: `/`, `/:nip19`, `*` +- `src/pages/` — `Index.tsx` (Platzhalter), `NIP19Page.tsx`, `NotFound.tsx` +- `src/components/ui/` — vollständiges shadcn/ui-Set (48+ Komponenten) +- Nostr-Infrastruktur vorhanden: `NostrProvider`, `useNostr`, `useAuthor`, + `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `LoginArea` +- Theming über CSS-Variablen in `src/index.css` (`:root` / `.dark`), `useTheme` + +Es gibt also **noch keine Feature-Seiten, die migriert werden müssten** — der Window-Manager +kann von Anfang an als Fundament gebaut werden, statt nachträglich übergestülpt zu werden. +Das ist der günstigste Zeitpunkt für diese Architektur. + +**Dependencies sparsam.** Drag/Resize wird mit Pointer-Events selbst implementiert +(~150 Zeilen), weil dnd-Bibliotheken für Fenster-Dragging überdimensioniert sind und wir +volle Kontrolle über Snapping, Grenzen und Touch-Verhalten brauchen. Ebenso kein neues +State-Lib — `useReducer` + Context genügen. Einzige Neuzugänge sind die drei +Markdown-Pakete für die Artikel-App (§6.1), die ausschließlich in deren Lazy-Chunk landen. + +--- + +## 3. Architektur + +### 3.1 Schichtenmodell + +``` +┌─────────────────────────────────────────────┐ +│ MenuBar (fix, oben, 28px) │ z-index 100 +├─────────────────────────────────────────────┤ +│ │ +│ Desktop (Wallpaper + App-Icons) │ z-index 0 +│ │ +│ ┌──────────────┐ │ +│ │ WindowFrame │ ┌──────────────┐ │ z-index 10..99 +│ │ │ │ WindowFrame │ │ (Stapel nach Fokus) +│ └──────────────┘ └──────────────┘ │ +│ │ +├─────────────────────────────────────────────┤ +└─────────────────────────────────────────────┘ + (kein Dock — volle Desktopfläche) +``` + +### 3.2 Neue Verzeichnisstruktur + +``` +src/ + os/ + registry.ts # App-Registry: Single Source of Truth + types.ts # AppDefinition, WindowState, ... + WindowManagerContext.ts + WindowManagerProvider.tsx + windowReducer.ts # reine Reducer-Logik (gut testbar) + useWindowManager.ts + useDrag.ts # Pointer-basiertes Drag + useResize.ts # Pointer-basiertes Resize (8 Handles) + layout.ts # Kaskade, Snapping, Clamping, Zentrierung + persistence.ts # localStorage-Serialisierung + components/os/ + Desktop.tsx # Wallpaper + Icon-Grid + Marquee-Auswahl + DesktopIcon.tsx + MenuBar.tsx # macOS-Leiste oben + MenuBarClock.tsx + WindowLayer.tsx # rendert alle offenen Fenster + WindowFrame.tsx # Chrome: Titlebar, Traffic Lights, Resize-Handles + TrafficLights.tsx + MobileAppShell.tsx # Fullscreen-Fallback für Mobile + AppChrome.tsx # Toolbar/Sidebar-Bausteine für App-Inhalte + apps/ + / + index.tsx # der Fenster-Inhalt + definition.ts # Metadaten für die Registry +``` + +`src/pages/` bleibt bestehen, schrumpft aber auf: `Index.tsx` (rendert nur noch +den ``-Shell), `NIP19Page.tsx`, `NotFound.tsx`. + +### 3.3 App-Registry + +Die Registry ist der zentrale Katalog. Eine neue App hinzufügen = **ein Eintrag**, +keine Router-Änderung, kein Menü-Update, kein Icon-Grid-Update. + +```ts +// src/os/types.ts +export interface AppDefinition { + id: string; // 'feed', 'settings', 'profile' + title: string; // "Feed" + icon: LucideIcon; + category: 'social' | 'tools' | 'system'; + component: React.LazyExoticComponent>; + defaultSize: { width: number; height: number }; + minSize?: { width: number; height: number }; + resizable?: boolean; // default true + singleton?: boolean; // nur eine Instanz (z.B. Settings) — default true + showOnDesktop?: boolean; // default true + requiresAuth?: boolean; // zeigt Login-Prompt statt Inhalt +} + +export interface AppProps { + windowId: string; + params?: Record; // z.B. { npub: '...' } bei Deep-Link + setTitle: (title: string) => void; // App kann Fenstertitel setzen +} +``` + +Alle App-Komponenten werden per `React.lazy()` geladen → das initiale Bundle enthält +nur den Shell. Ein Fenster ist ein Code-Split-Punkt. `` in `WindowFrame` +zeigt einen Skeleton-Inhalt. + +### 3.4 Window-State + +```ts +export interface WindowState { + id: string; // nanoid-artig, `${appId}-${counter}` + appId: string; + title: string; + x: number; y: number; // Position relativ zum Desktop-Bereich + width: number; height: number; + z: number; // Stapelreihenfolge + minimized: boolean; + maximized: boolean; + prevRect?: Rect; // Rect vor dem Maximieren (für Restore) + params?: Record; +} +``` + +Verwaltung über `useReducer` + Context (`WindowManagerProvider`), **kein neues +State-Lib**. Actions: + +`OPEN_APP` · `CLOSE_WINDOW` · `FOCUS_WINDOW` · `MOVE_WINDOW` · `RESIZE_WINDOW` · +`MINIMIZE` · `RESTORE` · `TOGGLE_MAXIMIZE` · `SET_TITLE` · `CLOSE_ALL` · `HYDRATE` + +Wichtige Reducer-Regeln: + +- `OPEN_APP` bei `singleton: true` und bereits offenem Fenster → fokussieren + + ggf. entminimieren statt neue Instanz. +- `z` wird beim Fokussieren auf `maxZ + 1` gesetzt. Bei `z > 9000` einmalig + normalisieren (alle Fenster neu durchnummerieren), um Overflow-Drift zu vermeiden. +- Neue Fenster erscheinen **kaskadiert** (je +28px x/y, Reset nach 6 Fenstern), + auf sichtbaren Bereich geclamped, initial mittig-versetzt. + +**Performance:** Während Drag/Resize wird die Position **nicht** in den Reducer +geschrieben (das würde bei jedem Pointer-Move alle Fenster neu rendern). Stattdessen +schreibt das Drag-Hook direkt per `transform` auf das DOM-Element und dispatcht +**einmal auf `pointerup`**. Zusätzlich bekommt jedes `WindowFrame` ein `React.memo`. + +### 3.5 Routing & Deep-Links + +Der bestehende Router bleibt intakt (Vorgabe aus `AGENTS.md`: `/:nip19` darf nicht +verschachtelt werden). Der OS-Zustand lebt im **Query-String**: + +``` +/ → Desktop, keine Fenster (oder wiederhergestellte Session) +/?app=feed → Desktop mit geöffnetem Feed-Fenster +/?app=profile&npub=npub1... → Profil-App mit Parameter +/npub1abc... → NIP19Page: öffnet Desktop + passende App im Fenster +``` + +- `Index.tsx` liest beim Mount `?app=` und öffnet die entsprechenden Fenster. +- `NIP19Page.tsx` rendert ebenfalls den Desktop und öffnet je nach Identifier-Typ + (`npub`/`nprofile` → Profil-App, `note`/`nevent` → Thread-App, `naddr` → Artikel-App) + ein Fenster mit den dekodierten Parametern. +- Beim Fokuswechsel wird die URL per `replaceState` auf die aktive App aktualisiert, + damit „Link kopieren" und Browser-Reload das Erwartete tun. Kein History-Spam: + Fensterbewegungen schreiben **nie** in die URL. +- Jedes Fenster hat im Kontextmenü „Link kopieren". + +### 3.6 Persistenz + +`localStorage`-Key `nostr:os-session` (v1-versioniert): +offene Fenster, Positionen, Größen, Z-Order, Minimiert-Status. + +- Beim Start: Hydration → jedes Fenster gegen aktuelle Viewport-Größe clampen + (Fenster außerhalb des Bildschirms zurückholen). +- Unbekannte `appId` (App wurde entfernt) wird beim Hydrieren still verworfen. +- Alles in `try/catch`; kaputter/fehlender State ⇒ leerer Desktop, nie ein Crash. +- Schreiben debounced (300 ms). +- Im Menü: „Fenster › Alle schließen" und „Sitzung zurücksetzen". + +--- + +## 4. Design-System (PostHog-Kalibrierung) + +### 4.1 Farben + +`src/index.css` wird angepasst — die vorhandenen Token-Namen bleiben (shadcn hängt +daran), nur die Werte ändern sich, plus neue OS-Token. + +| Token | Light | Zweck | +|---|---|---| +| `--os-desktop` | `hsl(40 20% 96%)` | warmes Off-White als Wallpaper-Basis | +| `--background` | `hsl(0 0% 100%)` | Fenster-Inhalt | +| `--foreground` | `hsl(20 14% 12%)` | fast-schwarz, leicht warm | +| `--border` | `hsl(30 10% 88%)` | 1px-Hairlines | +| `--primary` | `hsl(265 85% 60%)` | Nostr-Violett als Akzent | +| `--os-titlebar` | `hsl(40 15% 98%)` | Titelleiste aktiv | +| `--os-titlebar-inactive` | `hsl(40 10% 96%)` | Titelleiste inaktiv | + +Dark Mode ist gleichwertig, nicht Nachgedanke: `--os-desktop: hsl(24 10% 8%)`, +Fenster `hsl(24 8% 12%)`, Rahmen `hsl(24 6% 22%)`. + +### 4.2 Materialien + +- **Fenster:** `bg-background`, `border border-border`, `rounded-xl`, + Schatten in zwei Stufen — fokussiert `shadow-2xl`, unfokussiert `shadow-md` + + leicht reduzierte Deckkraft der Titelleiste. Der Fokus muss **auf einen Blick** + erkennbar sein. +- **Menüleiste:** `backdrop-blur-md` + halbtransparentes Weiß. Der *einzige* + Ort mit Blur — das hält es besonders statt beliebig. +- **Radius:** `--radius: 0.75rem` (bereits gesetzt) passt; Fenster `xl`, Buttons `md`. +- **Bewegung:** kurz und funktional. Öffnen 160 ms `scale(.96)→1` + Fade, + Minimieren 200 ms Richtung Menüleiste, Schließen 120 ms Fade. + Alles respektiert `prefers-reduced-motion` (dann: nur Opazität, keine Transforms). + +### 4.3 Typografie + +- UI-Text: System-Stack (`-apple-system, ui-sans-serif, …`), 13px Basis in Chrome-Flächen, + 14–15px in App-Inhalten. +- Überschriften in Apps: klar größer, `font-semibold`, `tracking-tight`. +- Monospace (`ui-monospace`) für technische Werte: Pubkeys, Event-IDs, Relay-URLs. + +### 4.4 „App statt Website" + +Damit sich Inhalte nicht wie Landingpages anfühlen, gilt für jeden App-Inhalt: + +- **Keine** eigene Page-Kopfzeile mit riesigem H1 — der Fenstertitel ist die Überschrift. +- Kein zentrierter Content mit `max-w-4xl mx-auto` und viel Luft daneben; Layout + füllt das Fenster (`h-full`, Flex/Grid). +- Scrollen passiert **innerhalb** des Fensters, nie auf `body`. +- Optionale App-Toolbar direkt unter der Titelleiste (Suche, Filter, Aktionen), + 36px hoch, `border-b`. +- Optionale App-Sidebar links (200–240px), wenn die App Navigation braucht — + wie Mail.app oder Finder. +- Leerzustände sind knapp und handlungsorientiert („Noch keine Notizen · Neue anlegen"), + keine Marketing-Illustrationen. +- Dichte statt Weite: Listenzeilen ~44px, keine Karten-mit-Riesenpadding. + +--- + +## 5. Komponenten im Detail + +### 5.1 MenuBar (oben, fix, 28px) + +``` +[◆ Logo] [App-Name ▾] [Datei ▾] [Ansicht ▾] [Fenster ▾] ······ [Relays] [Theme] [Uhr] [Avatar ▾] +``` + +- **Links:** Logo-Menü (Über, Einstellungen…, Sitzung zurücksetzen). +- **App-Menü:** Name des *fokussierten* Fensters in `font-semibold` — der macOS-Kern-Trick, + der die OS-Illusion trägt. Ohne Fokus: „Finder"-Äquivalent, hier „Desktop". +- **Fenster-Menü:** Liste aller offenen Fenster mit Häkchen beim aktiven, plus + „Alle minimieren" / „Alle schließen". +- **Rechts:** Relay-Status (Punkt grün/gelb/rot + Anzahl verbundener Relays), + Theme-Toggle, Uhrzeit (Locale-formatiert, minütlich aktualisiert), Login/Avatar + (bestehende `LoginArea`, in Menü-Optik umgestylt). +- Umsetzung mit dem vorhandenen shadcn `menubar` bzw. `dropdown-menu` — Keyboard-Navigation + und Fokus-Handling sind damit geschenkt. + +### 5.2 Desktop + +- Wallpaper: **feines Punktraster** (~22px Abstand, sehr geringer Kontrast) auf + `--os-desktop`. Kein Foto, kein Verlauf — das Raster gibt Textur und Tiefe, ohne den + Fensterinhalten Aufmerksamkeit zu stehlen. Umsetzung als CSS `radial-gradient` + + `background-size`, ein einziger Token für die Punktfarbe (Light/Dark). +- Icon-Grid: oben links beginnend, spaltenweise nach unten (macOS-Konvention), + ~80px Zellen, Icon + Label. +- Interaktion: einfacher Klick = Auswahl, **Doppelklick = öffnen** (Touch: einfacher Tap), + Enter öffnet Auswahl, Pfeiltasten navigieren. +- Rechtsklick auf leere Fläche → Kontextmenü (Hintergrund wechseln, Symbole aufräumen, + Alle Fenster schließen). + +### 5.3 WindowFrame + +- **Titelleiste** 36px: links Traffic Lights, mittig Titel (`truncate`, 13px, `font-medium`), + rechts optionaler App-Aktionsslot. +- **Traffic Lights:** rot/gelb/grün, 12px, Symbole (×, −, ⤢) erscheinen erst beim + Hover über die Gruppe. Unfokussiert alle grau. + Jeweils echte ` -
- - ); -} -``` - -#### Streaming Chat Example - -```tsx -function StreamingChat() { - const { sendStreamingMessage } = useShakespeare(); - const [messages, setMessages] = useState([]); - const [currentResponse, setCurrentResponse] = useState(''); - const [selectedModel, setSelectedModel] = useState(''); - - const handleStreaming = async (content: string) => { - if (!selectedModel) return; - - setCurrentResponse(''); - const newMessages = [...messages, { role: 'user', content }]; - setMessages(newMessages); - - try { - await sendStreamingMessage(newMessages, selectedModel, (chunk) => { - setCurrentResponse(prev => prev + chunk); - }); - - // Add the complete response to messages - if (currentResponse.trim()) { - setMessages(prev => [...prev, { - role: 'assistant', - content: currentResponse - }]); - } - } catch (err) { - console.error('Streaming error:', err); - } finally { - setCurrentResponse(''); - } - }; - - return ( -
- {/* Model selection UI */} -
- -
- - {/* Chat interface */} - {/* ... rest of your chat UI */} -
- ); -} -``` - -#### Model Information - -Models are dynamically fetched from the Shakespeare API and include: - -- **Model ID**: Unique identifier for the model -- **Name**: Human-readable model name -- **Description**: Model capabilities and use cases -- **Context Window**: Maximum token limit for conversations -- **Pricing**: Cost per token for prompt and completion -- **Free Models**: Models with `pricing.prompt === "0"` and `pricing.completion === "0"` - -#### Key Points - -- **Dynamic Model Discovery**: Always fetch available models using `getAvailableModels()` -- **Authentication Required**: User must be logged in with Nostr account -- **Free vs Premium**: Check pricing to determine if model requires credits -- **Error Handling**: Handle `isLoading` and `error` states appropriately -- **Model Selection**: Provide UI for users to choose between available models - -## Implementation Patterns and Best Practices - -### Dialog Component Patterns - -When using Dialog components, always ensure accessibility compliance by including required elements: - -```tsx -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; - -// ✅ Correct - Always include DialogHeader with DialogTitle - - - - Dialog Title - - Optional description for screen readers - - - {/* Dialog content */} - - -``` - -**Important**: Even if you want to hide the title visually, use the `VisuallyHidden` component to maintain accessibility: - -```tsx -import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; - - - - Hidden Title for Screen Readers - - -``` - -### Streaming Response Handling - -When implementing streaming chat interfaces, always accumulate streamed content in a local variable before clearing the streaming state to prevent content loss: - -```tsx -const handleStreamingResponse = async () => { - let streamedContent = ''; // ✅ Use local variable to accumulate content - - try { - await sendStreamingMessage(messages, model, (chunk) => { - streamedContent += chunk; // ✅ Accumulate in local variable - setCurrentStreamingMessage(streamedContent); // Update UI - }); - - // ✅ Save accumulated content to persistent state - if (streamedContent.trim()) { - const assistantMessage: MessageDisplay = { - id: Date.now().toString(), - role: 'assistant', - content: streamedContent, // ✅ Use accumulated content - timestamp: new Date() - }; - setMessages(prev => [...prev, assistantMessage]); - } - } finally { - setCurrentStreamingMessage(''); // ✅ Clear streaming state after saving - } -}; -``` - -### Error Boundary Patterns - -Always wrap AI components with error boundaries and provide user-friendly error messages for common failure scenarios: - -```tsx -import { ErrorBoundary } from '@/components/ErrorBoundary'; -import { Alert, AlertDescription } from '@/components/ui/alert'; - -function AIChatWithErrorBoundary() { - return ( - - - - - Something went wrong with the AI chat. Please refresh the page and try again. - - - - } - > - - - ); -} - -// In your AI component, handle specific error types gracefully: -function useAIWithErrorHandling() { - const { sendChatMessage, error, clearError } = useShakespeare(); - - const sendMessage = async (messages: ChatMessage[], modelId: string) => { - try { - await sendChatMessage(messages, modelId); - } catch (err) { - // Handle specific error types with user-friendly messages - if (err.message.includes('401')) { - throw new Error('Authentication failed. Please log in again.'); - } else if (err.message.includes('402')) { - throw new Error('Insufficient credits. Please add credits to use premium features.'); - } else if (err.message.includes('network')) { - throw new Error('Network error. Please check your internet connection.'); - } - throw err; // Re-throw for error boundary - } - }; - - return { sendMessage, error, clearError }; -} -``` diff --git a/docs/NOSTR_COMMENTS.md b/docs/NOSTR_COMMENTS.md deleted file mode 100644 index 0e99cec..0000000 --- a/docs/NOSTR_COMMENTS.md +++ /dev/null @@ -1,54 +0,0 @@ -# Adding Nostr Comments Sections - -The project includes a complete commenting system using NIP-22 (kind 1111) comments that can be added to any Nostr event or URL. The `CommentsSection` component provides a full-featured commenting interface with threaded replies, user authentication, and real-time updates. - -## Basic Usage - -```tsx -import { CommentsSection } from "@/components/comments/CommentsSection"; - -function ArticlePage({ article }: { article: NostrEvent }) { - return ( -
- {/* Your article content */} -
{/* article content */}
- - {/* Comments section */} - -
- ); -} -``` - -## Props and Customization - -The `CommentsSection` component accepts the following props: - -- **`root`** (required): The root event or URL to comment on. Can be a `NostrEvent` or `URL` object. -- **`title`**: Custom title for the comments section (default: "Comments") -- **`emptyStateMessage`**: Message shown when no comments exist (default: "No comments yet") -- **`emptyStateSubtitle`**: Subtitle for empty state (default: "Be the first to share your thoughts!") -- **`className`**: Additional CSS classes for styling -- **`limit`**: Maximum number of comments to load (default: 500) - -```tsx - -``` - -## Commenting on URLs - -The comments system also supports commenting on external URLs, making it useful for web pages, articles, or any online content: - -```tsx - -``` diff --git a/docs/NOSTR_DIRECT_MESSAGES.md b/docs/NOSTR_DIRECT_MESSAGES.md deleted file mode 100644 index 9513603..0000000 --- a/docs/NOSTR_DIRECT_MESSAGES.md +++ /dev/null @@ -1,473 +0,0 @@ -### Direct Messaging on Nostr - -This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage. - -**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application. - -## Setup Instructions - -### 1. Add DMProvider to Your App - -First, add the `DMProvider` to your app's provider tree in `src/App.tsx`: - -```tsx -// Add these imports at the top of src/App.tsx -import { DMProvider, type DMConfig } from '@/components/DMProvider'; -import { PROTOCOL_MODE } from '@/lib/dmConstants'; - -// Add this configuration before your App component -const dmConfig: DMConfig = { - // Enable or disable DMs entirely - enabled: true, // Set to true to enable messaging functionality - - // Choose one protocol mode: - // PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only - // PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only - // PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17) - protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps -}; - -// Then wrap your app components with DMProvider: -export function App() { - return ( - - - - - - - - - - - - - - - - - - - - ); -} -``` - -### 2. Configure DM Settings - -The `DMConfig` object supports the following options: - -- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed. -- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support: - - `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only - - `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended) - - `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility) - -**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain. - -## Quick Start - -### 1. Send Messages - -```tsx -import { useDMContext } from '@/hooks/useDMContext'; -import { MESSAGE_PROTOCOL } from '@/lib/dmConstants'; - -function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) { - const { sendMessage } = useDMContext(); - const [content, setContent] = useState(''); - - const handleSend = async () => { - await sendMessage({ - recipientPubkey, - content, - protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping - }); - setContent(''); - }; - - return ( -
{ e.preventDefault(); handleSend(); }}> -