From 2aa0a8c46065dbf12bf7f88a349b5ba805a680e9 Mon Sep 17 00:00:00 2001 From: mleku Date: Sun, 28 Dec 2025 04:00:16 +0200 Subject: [PATCH] feat: add QR scanner, improve UX, and simplify navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add live camera QR scanner for nsec/ncryptsec login - Replace browser prompt() with proper password dialog for ncryptsec - Add missing /notes/:id route for thread view navigation - Remove explore section entirely (button, page, routes) - Remove profile button from bottom nav, avatar now opens profile - Remove "Notes" tab from feed, default to showing all posts/replies - Add PasswordPromptProvider for secure password input - Add SidebarDrawer for mobile navigation - Add domain layer with value objects and adapters - Various UI and navigation improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/commands/release.md | 40 + .claude/skills/applesauce-core/SKILL.md | 634 ++++++++ .claude/skills/applesauce-signers/SKILL.md | 757 ++++++++++ .claude/skills/cypher/SKILL.md | 395 +++++ .../cypher/references/common-mistakes.md | 381 +++++ .../cypher/references/common-patterns.md | 397 +++++ .../cypher/references/syntax-reference.md | 540 +++++++ .claude/skills/distributed-systems/SKILL.md | 1115 ++++++++++++++ .../references/consensus-protocols.md | 610 ++++++++ .../references/logical-clocks.md | 610 ++++++++ .claude/skills/domain-driven-design/SKILL.md | 166 +++ .../references/anti-patterns.md | 853 +++++++++++ .../references/strategic-patterns.md | 358 +++++ .../references/tactical-patterns.md | 927 ++++++++++++ .claude/skills/elliptic-curves/SKILL.md | 369 +++++ .../elliptic-curves/references/algorithms.md | 513 +++++++ .../references/secp256k1-parameters.md | 194 +++ .../elliptic-curves/references/security.md | 291 ++++ .../skills/go-memory-optimization/SKILL.md | 478 ++++++ .../references/patterns.md | 594 ++++++++ .claude/skills/golang/SKILL.md | 268 ++++ .../golang/references/common-patterns.md | 649 ++++++++ .../golang/references/effective-go-summary.md | 423 ++++++ .../golang/references/quick-reference.md | 528 +++++++ .claude/skills/ndk/INDEX.md | 286 ++++ .claude/skills/ndk/README.md | 38 + .../skills/ndk/examples/01-initialization.ts | 162 ++ .../skills/ndk/examples/02-authentication.ts | 255 ++++ .../ndk/examples/03-publishing-events.ts | 376 +++++ .../ndk/examples/04-querying-subscribing.ts | 404 +++++ .../skills/ndk/examples/05-users-profiles.ts | 423 ++++++ .claude/skills/ndk/examples/README.md | 94 ++ .claude/skills/ndk/ndk-skill.md | 701 +++++++++ .claude/skills/ndk/quick-reference.md | 351 +++++ .claude/skills/ndk/troubleshooting.md | 530 +++++++ .claude/skills/nostr-tools/SKILL.md | 767 ++++++++++ .claude/skills/nostr-websocket/SKILL.md | 978 ++++++++++++ .../references/khatru_implementation.md | 1275 ++++++++++++++++ .../references/rust_implementation.md | 1307 +++++++++++++++++ .../references/strfry_implementation.md | 921 ++++++++++++ .../references/websocket_protocol.md | 881 +++++++++++ .claude/skills/nostr/README.md | 162 ++ .claude/skills/nostr/SKILL.md | 459 ++++++ .../nostr/references/common-mistakes.md | 657 +++++++++ .../skills/nostr/references/event-kinds.md | 361 +++++ .../skills/nostr/references/nips-overview.md | 1170 +++++++++++++++ .claude/skills/react/README.md | 119 ++ .claude/skills/react/SKILL.md | 1026 +++++++++++++ .../react/examples/practical-patterns.tsx | 878 +++++++++++ .../react/references/hooks-quick-reference.md | 291 ++++ .../skills/react/references/performance.md | 658 +++++++++ .../react/references/server-components.md | 656 +++++++++ .claude/skills/rollup/SKILL.md | 899 ++++++++++++ .claude/skills/skill-creator/LICENSE.txt | 202 +++ .claude/skills/skill-creator/SKILL.md | 209 +++ .../quick_validate.cpython-310.pyc | Bin 0 -> 1675 bytes .../skill-creator/scripts/init_skill.py | 303 ++++ .../skill-creator/scripts/package_skill.py | 110 ++ .../skill-creator/scripts/quick_validate.py | 65 + .claude/skills/svelte/SKILL.md | 1004 +++++++++++++ .claude/skills/typescript/README.md | 133 ++ .claude/skills/typescript/SKILL.md | 359 +++++ .claude/skills/typescript/examples/README.md | 45 + .../typescript/examples/advanced-types.ts | 478 ++++++ .../typescript/examples/react-patterns.ts | 555 +++++++ .../typescript/examples/type-system-basics.ts | 361 +++++ .claude/skills/typescript/quick-reference.md | 395 +++++ .../typescript/references/common-patterns.md | 756 ++++++++++ .../typescript/references/type-system.md | 804 ++++++++++ .../typescript/references/utility-types.md | 666 +++++++++ CLAUDE.md | 105 ++ hotrefresh | 11 + index.html | 3 + public/apple-touch-icon.png | Bin 9266 -> 9643 bytes public/favicon-96x96.png | Bin 8272 -> 8551 bytes public/favicon.ico | Bin 9662 -> 9662 bytes public/favicon.png | Bin 10394 -> 9904 bytes public/pwa-192x192.png | Bin 9359 -> 9743 bytes public/pwa-512x512.png | Bin 14946 -> 16033 bytes resources/smeshdark.png | Bin 16763 -> 16828 bytes resources/smeshicondark.png | Bin 10394 -> 10983 bytes resources/smeshiconlight.png | Bin 6821 -> 7175 bytes resources/smeshlight.png | Bin 11780 -> 12124 bytes src/App.tsx | 18 +- src/PageManager.tsx | 86 +- src/application/PublishingService.ts | 187 +++ src/application/RelaySelector.ts | 188 +++ src/application/index.ts | 12 + src/assets/GiteaIcon.tsx | 13 + src/assets/smeshdark.png | Bin 16763 -> 16828 bytes src/assets/smeshicondark.png | Bin 10394 -> 10983 bytes src/assets/smeshiconlight.png | Bin 6821 -> 7175 bytes src/assets/smeshlight.png | Bin 11780 -> 12124 bytes src/components/AccountManager/BunkerLogin.tsx | 56 - .../AccountManager/NostrConnectionLogin.tsx | 270 ---- .../AccountManager/PrivateKeyLogin.tsx | 256 +++- src/components/AccountManager/index.tsx | 8 +- .../BottomNavigationBar/AccountButton.tsx | 26 +- .../BottomNavigationBar/BookmarkButton.tsx | 20 + .../BottomNavigationBar/PostButton.tsx | 21 + .../{ExploreButton.tsx => SearchButton.tsx} | 10 +- .../BottomNavigationBar/SettingsButton.tsx | 20 + src/components/BottomNavigationBar/index.tsx | 19 +- src/components/Explore/index.tsx | 103 -- src/components/NormalFeed/index.tsx | 12 +- src/components/PostEditor/PostContent.tsx | 149 +- .../PostEditor/PostRelaySelector.tsx | 295 ---- .../PostEditor/PostTextarea/index.tsx | 20 +- src/components/PostEditor/index.tsx | 59 +- src/components/RelayInfo/index.tsx | 2 +- src/components/SearchOverlay/index.tsx | 61 + src/components/Settings/index.tsx | 31 +- src/components/Sidebar/AccountButton.tsx | 38 +- src/components/Sidebar/ExploreButton.tsx | 20 - src/components/Sidebar/LayoutSwitcher.tsx | 6 + src/components/Sidebar/LogoutButton.tsx | 81 + src/components/Sidebar/index.tsx | 57 +- src/components/SidebarDrawer/index.tsx | 95 ++ src/components/SignerTypeBadge/index.tsx | 2 - src/components/Titlebar/index.tsx | 67 +- src/components/ui/accordion.tsx | 2 +- src/constants.ts | 15 +- src/domain/content/Note.ts | 257 ++++ src/domain/content/Reaction.ts | 202 +++ src/domain/content/Repost.ts | 146 ++ src/domain/content/adapters.ts | 175 +++ src/domain/content/errors.ts | 50 + src/domain/content/index.ts | 47 + src/domain/identity/Account.ts | 190 +++ src/domain/identity/SignerType.ts | 138 ++ src/domain/identity/adapters.ts | 171 +++ src/domain/identity/errors.ts | 50 + src/domain/identity/index.ts | 44 + src/domain/index.ts | 21 + src/domain/relay/FavoriteRelays.ts | 340 +++++ src/domain/relay/RelayList.ts | 295 ++++ src/domain/relay/RelaySet.ts | 245 +++ src/domain/relay/adapters.ts | 208 +++ src/domain/relay/errors.ts | 41 + src/domain/relay/index.ts | 52 + src/domain/relay/repositories.ts | 64 + src/domain/shared/adapters.ts | 174 +++ src/domain/shared/errors.ts | 34 + src/domain/shared/index.ts | 47 + src/domain/shared/value-objects/EventId.ts | 146 ++ src/domain/shared/value-objects/Pubkey.ts | 121 ++ src/domain/shared/value-objects/RelayUrl.ts | 117 ++ src/domain/shared/value-objects/Timestamp.ts | 188 +++ src/domain/shared/value-objects/index.ts | 20 + src/domain/social/FollowList.ts | 229 +++ src/domain/social/MuteList.ts | 307 ++++ src/domain/social/adapters.ts | 225 +++ src/domain/social/errors.ts | 50 + src/domain/social/events.ts | 143 ++ src/domain/social/index.ts | 63 + src/domain/social/repositories.ts | 49 + src/i18n/locales/en.ts | 3 + src/index.css | 3 + src/layouts/PrimaryPageLayout/index.tsx | 2 +- src/layouts/SecondaryPageLayout/index.tsx | 2 +- src/lib/draft-event.ts | 12 +- src/lib/link.ts | 2 + src/pages/primary/ExplorePage/index.tsx | 109 -- src/pages/primary/NoteListPage/index.tsx | 48 +- src/pages/primary/ProfilePage/index.tsx | 23 +- src/pages/secondary/LoginPage/index.tsx | 20 + src/pages/secondary/LogoutPage/index.tsx | 37 + .../secondary/SystemSettingsPage/index.tsx | 3 +- src/providers/ContentPolicyProvider.tsx | 7 +- src/providers/KindFilterProvider.tsx | 3 +- src/providers/NostrProvider/bunker.signer.ts | 66 - src/providers/NostrProvider/index.tsx | 94 +- .../NostrProvider/nostrConnection.signer.ts | 72 - src/providers/NostrProvider/nsec.signer.ts | 51 +- src/providers/PasswordPromptProvider.tsx | 87 ++ src/providers/ScreenSizeProvider.tsx | 35 +- src/providers/SettingsSyncProvider.tsx | 252 ++++ src/providers/ThemeProvider.tsx | 4 +- src/providers/UserPreferencesProvider.tsx | 15 +- src/providers/UserTrustProvider.tsx | 5 +- src/providers/ZapProvider.tsx | 5 +- src/routes/primary.tsx | 2 - src/routes/secondary.tsx | 4 + src/services/local-storage.service.ts | 6 + src/services/media-upload.service.ts | 113 +- src/types/index.d.ts | 28 +- tailwind.config.js | 6 + 187 files changed, 42378 insertions(+), 1454 deletions(-) create mode 100644 .claude/commands/release.md create mode 100644 .claude/skills/applesauce-core/SKILL.md create mode 100644 .claude/skills/applesauce-signers/SKILL.md create mode 100644 .claude/skills/cypher/SKILL.md create mode 100644 .claude/skills/cypher/references/common-mistakes.md create mode 100644 .claude/skills/cypher/references/common-patterns.md create mode 100644 .claude/skills/cypher/references/syntax-reference.md create mode 100644 .claude/skills/distributed-systems/SKILL.md create mode 100644 .claude/skills/distributed-systems/references/consensus-protocols.md create mode 100644 .claude/skills/distributed-systems/references/logical-clocks.md create mode 100644 .claude/skills/domain-driven-design/SKILL.md create mode 100644 .claude/skills/domain-driven-design/references/anti-patterns.md create mode 100644 .claude/skills/domain-driven-design/references/strategic-patterns.md create mode 100644 .claude/skills/domain-driven-design/references/tactical-patterns.md create mode 100644 .claude/skills/elliptic-curves/SKILL.md create mode 100644 .claude/skills/elliptic-curves/references/algorithms.md create mode 100644 .claude/skills/elliptic-curves/references/secp256k1-parameters.md create mode 100644 .claude/skills/elliptic-curves/references/security.md create mode 100644 .claude/skills/go-memory-optimization/SKILL.md create mode 100644 .claude/skills/go-memory-optimization/references/patterns.md create mode 100644 .claude/skills/golang/SKILL.md create mode 100644 .claude/skills/golang/references/common-patterns.md create mode 100644 .claude/skills/golang/references/effective-go-summary.md create mode 100644 .claude/skills/golang/references/quick-reference.md create mode 100644 .claude/skills/ndk/INDEX.md create mode 100644 .claude/skills/ndk/README.md create mode 100644 .claude/skills/ndk/examples/01-initialization.ts create mode 100644 .claude/skills/ndk/examples/02-authentication.ts create mode 100644 .claude/skills/ndk/examples/03-publishing-events.ts create mode 100644 .claude/skills/ndk/examples/04-querying-subscribing.ts create mode 100644 .claude/skills/ndk/examples/05-users-profiles.ts create mode 100644 .claude/skills/ndk/examples/README.md create mode 100644 .claude/skills/ndk/ndk-skill.md create mode 100644 .claude/skills/ndk/quick-reference.md create mode 100644 .claude/skills/ndk/troubleshooting.md create mode 100644 .claude/skills/nostr-tools/SKILL.md create mode 100644 .claude/skills/nostr-websocket/SKILL.md create mode 100644 .claude/skills/nostr-websocket/references/khatru_implementation.md create mode 100644 .claude/skills/nostr-websocket/references/rust_implementation.md create mode 100644 .claude/skills/nostr-websocket/references/strfry_implementation.md create mode 100644 .claude/skills/nostr-websocket/references/websocket_protocol.md create mode 100644 .claude/skills/nostr/README.md create mode 100644 .claude/skills/nostr/SKILL.md create mode 100644 .claude/skills/nostr/references/common-mistakes.md create mode 100644 .claude/skills/nostr/references/event-kinds.md create mode 100644 .claude/skills/nostr/references/nips-overview.md create mode 100644 .claude/skills/react/README.md create mode 100644 .claude/skills/react/SKILL.md create mode 100644 .claude/skills/react/examples/practical-patterns.tsx create mode 100644 .claude/skills/react/references/hooks-quick-reference.md create mode 100644 .claude/skills/react/references/performance.md create mode 100644 .claude/skills/react/references/server-components.md create mode 100644 .claude/skills/rollup/SKILL.md create mode 100644 .claude/skills/skill-creator/LICENSE.txt create mode 100644 .claude/skills/skill-creator/SKILL.md create mode 100644 .claude/skills/skill-creator/scripts/__pycache__/quick_validate.cpython-310.pyc create mode 100755 .claude/skills/skill-creator/scripts/init_skill.py create mode 100755 .claude/skills/skill-creator/scripts/package_skill.py create mode 100755 .claude/skills/skill-creator/scripts/quick_validate.py create mode 100644 .claude/skills/svelte/SKILL.md create mode 100644 .claude/skills/typescript/README.md create mode 100644 .claude/skills/typescript/SKILL.md create mode 100644 .claude/skills/typescript/examples/README.md create mode 100644 .claude/skills/typescript/examples/advanced-types.ts create mode 100644 .claude/skills/typescript/examples/react-patterns.ts create mode 100644 .claude/skills/typescript/examples/type-system-basics.ts create mode 100644 .claude/skills/typescript/quick-reference.md create mode 100644 .claude/skills/typescript/references/common-patterns.md create mode 100644 .claude/skills/typescript/references/type-system.md create mode 100644 .claude/skills/typescript/references/utility-types.md create mode 100644 CLAUDE.md create mode 100755 hotrefresh create mode 100644 src/application/PublishingService.ts create mode 100644 src/application/RelaySelector.ts create mode 100644 src/application/index.ts create mode 100644 src/assets/GiteaIcon.tsx delete mode 100644 src/components/AccountManager/BunkerLogin.tsx delete mode 100644 src/components/AccountManager/NostrConnectionLogin.tsx create mode 100644 src/components/BottomNavigationBar/BookmarkButton.tsx create mode 100644 src/components/BottomNavigationBar/PostButton.tsx rename src/components/BottomNavigationBar/{ExploreButton.tsx => SearchButton.tsx} (57%) create mode 100644 src/components/BottomNavigationBar/SettingsButton.tsx delete mode 100644 src/components/Explore/index.tsx delete mode 100644 src/components/PostEditor/PostRelaySelector.tsx create mode 100644 src/components/SearchOverlay/index.tsx delete mode 100644 src/components/Sidebar/ExploreButton.tsx create mode 100644 src/components/Sidebar/LogoutButton.tsx create mode 100644 src/components/SidebarDrawer/index.tsx create mode 100644 src/domain/content/Note.ts create mode 100644 src/domain/content/Reaction.ts create mode 100644 src/domain/content/Repost.ts create mode 100644 src/domain/content/adapters.ts create mode 100644 src/domain/content/errors.ts create mode 100644 src/domain/content/index.ts create mode 100644 src/domain/identity/Account.ts create mode 100644 src/domain/identity/SignerType.ts create mode 100644 src/domain/identity/adapters.ts create mode 100644 src/domain/identity/errors.ts create mode 100644 src/domain/identity/index.ts create mode 100644 src/domain/index.ts create mode 100644 src/domain/relay/FavoriteRelays.ts create mode 100644 src/domain/relay/RelayList.ts create mode 100644 src/domain/relay/RelaySet.ts create mode 100644 src/domain/relay/adapters.ts create mode 100644 src/domain/relay/errors.ts create mode 100644 src/domain/relay/index.ts create mode 100644 src/domain/relay/repositories.ts create mode 100644 src/domain/shared/adapters.ts create mode 100644 src/domain/shared/errors.ts create mode 100644 src/domain/shared/index.ts create mode 100644 src/domain/shared/value-objects/EventId.ts create mode 100644 src/domain/shared/value-objects/Pubkey.ts create mode 100644 src/domain/shared/value-objects/RelayUrl.ts create mode 100644 src/domain/shared/value-objects/Timestamp.ts create mode 100644 src/domain/shared/value-objects/index.ts create mode 100644 src/domain/social/FollowList.ts create mode 100644 src/domain/social/MuteList.ts create mode 100644 src/domain/social/adapters.ts create mode 100644 src/domain/social/errors.ts create mode 100644 src/domain/social/events.ts create mode 100644 src/domain/social/index.ts create mode 100644 src/domain/social/repositories.ts delete mode 100644 src/pages/primary/ExplorePage/index.tsx create mode 100644 src/pages/secondary/LoginPage/index.tsx create mode 100644 src/pages/secondary/LogoutPage/index.tsx delete mode 100644 src/providers/NostrProvider/bunker.signer.ts delete mode 100644 src/providers/NostrProvider/nostrConnection.signer.ts create mode 100644 src/providers/PasswordPromptProvider.tsx create mode 100644 src/providers/SettingsSyncProvider.tsx diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 00000000..1e414366 --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,40 @@ +# Release Command + +Create a new version tag and optionally push it to the remote. + +## Arguments +- `major` - Increment major version (e.g., v0.1.1 -> v1.0.0) +- `minor` - Increment minor version (e.g., v0.1.1 -> v0.2.0) +- (default) - Increment patch version (e.g., v0.1.1 -> v0.1.2) +- `--push` - Push the tag to remote after creating + +## Instructions + +1. Get the latest version tag: + ```bash + git tag -l 'v*' --sort=-v:refname | head -1 + ``` + +2. If no tags exist, start with v0.1.0 as the base (next will be v0.1.1) + +3. Parse the version and increment based on the argument: + - Extract major, minor, patch from the tag (e.g., v1.2.3 -> 1, 2, 3) + - If argument is `major`: increment major, reset minor and patch to 0 + - If argument is `minor`: increment minor, reset patch to 0 + - Otherwise (default): increment patch + +4. Create the new tag: + ```bash + git tag -a v{VERSION} -m "Release v{VERSION}" + ``` + +5. Show the created tag and recent commits since the last tag + +6. If `--push` was specified, push the tag: + ```bash + git push origin v{VERSION} + ``` + +7. Display the new version and instructions for pushing if not auto-pushed + +$ARGUMENTS diff --git a/.claude/skills/applesauce-core/SKILL.md b/.claude/skills/applesauce-core/SKILL.md new file mode 100644 index 00000000..78307113 --- /dev/null +++ b/.claude/skills/applesauce-core/SKILL.md @@ -0,0 +1,634 @@ +--- +name: applesauce-core +description: This skill should be used when working with applesauce-core library for Nostr client development, including event stores, queries, observables, and client utilities. Provides comprehensive knowledge of applesauce patterns for building reactive Nostr applications. +--- + +# applesauce-core Skill + +This skill provides comprehensive knowledge and patterns for working with applesauce-core, a library that provides reactive utilities and patterns for building Nostr clients. + +## When to Use This Skill + +Use this skill when: +- Building reactive Nostr applications +- Managing event stores and caches +- Working with observable patterns for Nostr +- Implementing real-time updates +- Building timeline and feed views +- Managing replaceable events +- Working with profiles and metadata +- Creating efficient Nostr queries + +## Core Concepts + +### applesauce-core Overview + +applesauce-core provides: +- **Event stores** - Reactive event caching and management +- **Queries** - Declarative event querying patterns +- **Observables** - RxJS-based reactive patterns +- **Profile helpers** - Profile metadata management +- **Timeline utilities** - Feed and timeline building +- **NIP helpers** - NIP-specific utilities + +### Installation + +```bash +npm install applesauce-core +``` + +### Basic Architecture + +applesauce-core is built on reactive principles: +- Events are stored in reactive stores +- Queries return observables that update when new events arrive +- Components subscribe to observables for real-time updates + +## Event Store + +### Creating an Event Store + +```javascript +import { EventStore } from 'applesauce-core'; + +// Create event store +const eventStore = new EventStore(); + +// Add events +eventStore.add(event1); +eventStore.add(event2); + +// Add multiple events +eventStore.addMany([event1, event2, event3]); + +// Check if event exists +const exists = eventStore.has(eventId); + +// Get event by ID +const event = eventStore.get(eventId); + +// Remove event +eventStore.remove(eventId); + +// Clear all events +eventStore.clear(); +``` + +### Event Store Queries + +```javascript +// Get all events +const allEvents = eventStore.getAll(); + +// Get events by filter +const filtered = eventStore.filter({ + kinds: [1], + authors: [pubkey] +}); + +// Get events by author +const authorEvents = eventStore.getByAuthor(pubkey); + +// Get events by kind +const textNotes = eventStore.getByKind(1); +``` + +### Replaceable Events + +applesauce-core handles replaceable events automatically: + +```javascript +// For kind 0 (profile), only latest is kept +eventStore.add(profileEvent1); // stored +eventStore.add(profileEvent2); // replaces if newer + +// For parameterized replaceable (30000-39999) +eventStore.add(articleEvent); // keyed by author + kind + d-tag + +// Get replaceable event +const profile = eventStore.getReplaceable(0, pubkey); +const article = eventStore.getReplaceable(30023, pubkey, 'article-slug'); +``` + +## Queries + +### Query Patterns + +```javascript +import { createQuery } from 'applesauce-core'; + +// Create a query +const query = createQuery(eventStore, { + kinds: [1], + limit: 50 +}); + +// Subscribe to query results +query.subscribe(events => { + console.log('Current events:', events); +}); + +// Query updates automatically when new events added +eventStore.add(newEvent); // Subscribers notified +``` + +### Timeline Query + +```javascript +import { TimelineQuery } from 'applesauce-core'; + +// Create timeline for user's notes +const timeline = new TimelineQuery(eventStore, { + kinds: [1], + authors: [userPubkey] +}); + +// Get observable of timeline +const timeline$ = timeline.events$; + +// Subscribe +timeline$.subscribe(events => { + // Events sorted by created_at, newest first + renderTimeline(events); +}); +``` + +### Profile Query + +```javascript +import { ProfileQuery } from 'applesauce-core'; + +// Query profile metadata +const profileQuery = new ProfileQuery(eventStore, pubkey); + +// Get observable +const profile$ = profileQuery.profile$; + +profile$.subscribe(profile => { + if (profile) { + console.log('Name:', profile.name); + console.log('Picture:', profile.picture); + } +}); +``` + +## Observables + +### Working with RxJS + +applesauce-core uses RxJS observables: + +```javascript +import { map, filter, distinctUntilChanged } from 'rxjs/operators'; + +// Transform query results +const names$ = profileQuery.profile$.pipe( + filter(profile => profile !== null), + map(profile => profile.name), + distinctUntilChanged() +); + +// Combine multiple observables +import { combineLatest } from 'rxjs'; + +const combined$ = combineLatest([ + timeline$, + profile$ +]).pipe( + map(([events, profile]) => ({ + events, + authorName: profile?.name + })) +); +``` + +### Creating Custom Observables + +```javascript +import { Observable } from 'rxjs'; + +function createEventObservable(store, filter) { + return new Observable(subscriber => { + // Initial emit + subscriber.next(store.filter(filter)); + + // Subscribe to store changes + const unsubscribe = store.onChange(() => { + subscriber.next(store.filter(filter)); + }); + + // Cleanup + return () => unsubscribe(); + }); +} +``` + +## Profile Helpers + +### Profile Metadata + +```javascript +import { parseProfile, ProfileContent } from 'applesauce-core'; + +// Parse kind 0 content +const profileEvent = await getProfileEvent(pubkey); +const profile = parseProfile(profileEvent); + +// Profile fields +console.log(profile.name); // Display name +console.log(profile.about); // Bio +console.log(profile.picture); // Avatar URL +console.log(profile.banner); // Banner image URL +console.log(profile.nip05); // NIP-05 identifier +console.log(profile.lud16); // Lightning address +console.log(profile.website); // Website URL +``` + +### Profile Store + +```javascript +import { ProfileStore } from 'applesauce-core'; + +const profileStore = new ProfileStore(eventStore); + +// Get profile observable +const profile$ = profileStore.getProfile(pubkey); + +// Get multiple profiles +const profiles$ = profileStore.getProfiles([pubkey1, pubkey2]); + +// Request profile load (triggers fetch if not cached) +profileStore.requestProfile(pubkey); +``` + +## Timeline Utilities + +### Building Feeds + +```javascript +import { Timeline } from 'applesauce-core'; + +// Create timeline +const timeline = new Timeline(eventStore); + +// Add filter +timeline.setFilter({ + kinds: [1, 6], + authors: followedPubkeys +}); + +// Get events observable +const events$ = timeline.events$; + +// Load more (pagination) +timeline.loadMore(50); + +// Refresh (get latest) +timeline.refresh(); +``` + +### Thread Building + +```javascript +import { ThreadBuilder } from 'applesauce-core'; + +// Build thread from root event +const thread = new ThreadBuilder(eventStore, rootEventId); + +// Get thread observable +const thread$ = thread.thread$; + +thread$.subscribe(threadData => { + console.log('Root:', threadData.root); + console.log('Replies:', threadData.replies); + console.log('Reply count:', threadData.replyCount); +}); +``` + +### Reactions and Zaps + +```javascript +import { ReactionStore, ZapStore } from 'applesauce-core'; + +// Reactions +const reactionStore = new ReactionStore(eventStore); +const reactions$ = reactionStore.getReactions(eventId); + +reactions$.subscribe(reactions => { + console.log('Likes:', reactions.likes); + console.log('Custom:', reactions.custom); +}); + +// Zaps +const zapStore = new ZapStore(eventStore); +const zaps$ = zapStore.getZaps(eventId); + +zaps$.subscribe(zaps => { + console.log('Total sats:', zaps.totalAmount); + console.log('Zap count:', zaps.count); +}); +``` + +## NIP Helpers + +### NIP-05 Verification + +```javascript +import { verifyNip05 } from 'applesauce-core'; + +// Verify NIP-05 +const result = await verifyNip05('alice@example.com', expectedPubkey); + +if (result.valid) { + console.log('NIP-05 verified'); +} else { + console.log('Verification failed:', result.error); +} +``` + +### NIP-10 Reply Parsing + +```javascript +import { parseReplyTags } from 'applesauce-core'; + +// Parse reply structure +const parsed = parseReplyTags(event); + +console.log('Root event:', parsed.root); +console.log('Reply to:', parsed.reply); +console.log('Mentions:', parsed.mentions); +``` + +### NIP-65 Relay Lists + +```javascript +import { parseRelayList } from 'applesauce-core'; + +// Parse relay list event (kind 10002) +const relays = parseRelayList(relayListEvent); + +console.log('Read relays:', relays.read); +console.log('Write relays:', relays.write); +``` + +## Integration with nostr-tools + +### Using with SimplePool + +```javascript +import { SimplePool } from 'nostr-tools'; +import { EventStore } from 'applesauce-core'; + +const pool = new SimplePool(); +const eventStore = new EventStore(); + +// Load events into store +pool.subscribeMany(relays, [filter], { + onevent(event) { + eventStore.add(event); + } +}); + +// Query store reactively +const timeline$ = createTimelineQuery(eventStore, filter); +``` + +### Publishing Events + +```javascript +import { finalizeEvent } from 'nostr-tools'; + +// Create event +const event = finalizeEvent({ + kind: 1, + content: 'Hello!', + created_at: Math.floor(Date.now() / 1000), + tags: [] +}, secretKey); + +// Add to local store immediately (optimistic update) +eventStore.add(event); + +// Publish to relays +await pool.publish(relays, event); +``` + +## Svelte Integration + +### Using in Svelte Components + +```svelte + + +{#each events as event} +
+ {event.content} +
+{/each} +``` + +### Svelte Store Adapter + +```javascript +import { readable } from 'svelte/store'; + +// Convert RxJS observable to Svelte store +function fromObservable(observable, initialValue) { + return readable(initialValue, set => { + const subscription = observable.subscribe(set); + return () => subscription.unsubscribe(); + }); +} + +// Usage +const events$ = timeline.events$; +const eventsStore = fromObservable(events$, []); +``` + +```svelte + + +{#each $eventsStore as event} +
{event.content}
+{/each} +``` + +## Best Practices + +### Store Management + +1. **Single store instance** - Use one EventStore per app +2. **Clear stale data** - Implement cache limits +3. **Handle replaceable events** - Let store manage deduplication +4. **Unsubscribe** - Clean up subscriptions on component destroy + +### Query Optimization + +1. **Use specific filters** - Narrow queries perform better +2. **Limit results** - Use limit for initial loads +3. **Cache queries** - Reuse query instances +4. **Debounce updates** - Throttle rapid changes + +### Memory Management + +1. **Limit store size** - Implement LRU or time-based eviction +2. **Clean up observables** - Unsubscribe when done +3. **Use weak references** - For profile caches +4. **Paginate large feeds** - Don't load everything at once + +### Reactive Patterns + +1. **Prefer observables** - Over imperative queries +2. **Use operators** - Transform data with RxJS +3. **Combine streams** - For complex views +4. **Handle loading states** - Show placeholders + +## Common Patterns + +### Event Deduplication + +```javascript +// EventStore handles deduplication automatically +eventStore.add(event1); +eventStore.add(event1); // No duplicate + +// For manual deduplication +const seen = new Set(); +events.filter(e => { + if (seen.has(e.id)) return false; + seen.add(e.id); + return true; +}); +``` + +### Optimistic Updates + +```javascript +async function publishNote(content) { + // Create event + const event = await createEvent(content); + + // Add to store immediately (optimistic) + eventStore.add(event); + + try { + // Publish to relays + await pool.publish(relays, event); + } catch (error) { + // Remove on failure + eventStore.remove(event.id); + throw error; + } +} +``` + +### Loading States + +```javascript +import { BehaviorSubject, combineLatest } from 'rxjs'; + +const loading$ = new BehaviorSubject(true); +const events$ = timeline.events$; + +const state$ = combineLatest([loading$, events$]).pipe( + map(([loading, events]) => ({ + loading, + events, + empty: !loading && events.length === 0 + })) +); + +// Start loading +loading$.next(true); +await loadEvents(); +loading$.next(false); +``` + +### Infinite Scroll + +```javascript +function createInfiniteScroll(timeline, pageSize = 50) { + let loading = false; + + async function loadMore() { + if (loading) return; + + loading = true; + await timeline.loadMore(pageSize); + loading = false; + } + + function onScroll(event) { + const { scrollTop, scrollHeight, clientHeight } = event.target; + if (scrollHeight - scrollTop <= clientHeight * 1.5) { + loadMore(); + } + } + + return { loadMore, onScroll }; +} +``` + +## Troubleshooting + +### Common Issues + +**Events not updating:** +- Check subscription is active +- Verify events are being added to store +- Ensure filter matches events + +**Memory growing:** +- Implement store size limits +- Clean up subscriptions +- Use weak references where appropriate + +**Slow queries:** +- Add indexes for common queries +- Use more specific filters +- Implement pagination + +**Stale data:** +- Implement refresh mechanisms +- Set up real-time subscriptions +- Handle replaceable event updates + +## References + +- **applesauce GitHub**: https://github.com/hzrd149/applesauce +- **RxJS Documentation**: https://rxjs.dev +- **nostr-tools**: https://github.com/nbd-wtf/nostr-tools +- **Nostr Protocol**: https://github.com/nostr-protocol/nostr + +## Related Skills + +- **nostr-tools** - Lower-level Nostr operations +- **applesauce-signers** - Event signing abstractions +- **svelte** - Building reactive UIs +- **nostr** - Nostr protocol fundamentals diff --git a/.claude/skills/applesauce-signers/SKILL.md b/.claude/skills/applesauce-signers/SKILL.md new file mode 100644 index 00000000..40d07e6e --- /dev/null +++ b/.claude/skills/applesauce-signers/SKILL.md @@ -0,0 +1,757 @@ +--- +name: applesauce-signers +description: This skill should be used when working with applesauce-signers library for Nostr event signing, including NIP-07 browser extensions, NIP-46 remote signing, and custom signer implementations. Provides comprehensive knowledge of signing patterns and signer abstractions. +--- + +# applesauce-signers Skill + +This skill provides comprehensive knowledge and patterns for working with applesauce-signers, a library that provides signing abstractions for Nostr applications. + +## When to Use This Skill + +Use this skill when: +- Implementing event signing in Nostr applications +- Integrating with NIP-07 browser extensions +- Working with NIP-46 remote signers +- Building custom signer implementations +- Managing signing sessions +- Handling signing requests and permissions +- Implementing multi-signer support + +## Core Concepts + +### applesauce-signers Overview + +applesauce-signers provides: +- **Signer abstraction** - Unified interface for different signers +- **NIP-07 integration** - Browser extension support +- **NIP-46 support** - Remote signing (Nostr Connect) +- **Simple signers** - Direct key signing +- **Permission handling** - Manage signing requests +- **Observable patterns** - Reactive signing states + +### Installation + +```bash +npm install applesauce-signers +``` + +### Signer Interface + +All signers implement a common interface: + +```typescript +interface Signer { + // Get public key + getPublicKey(): Promise; + + // Sign event + signEvent(event: UnsignedEvent): Promise; + + // Encrypt (NIP-04) + nip04Encrypt?(pubkey: string, plaintext: string): Promise; + nip04Decrypt?(pubkey: string, ciphertext: string): Promise; + + // Encrypt (NIP-44) + nip44Encrypt?(pubkey: string, plaintext: string): Promise; + nip44Decrypt?(pubkey: string, ciphertext: string): Promise; +} +``` + +## Simple Signer + +### Using Secret Key + +```javascript +import { SimpleSigner } from 'applesauce-signers'; +import { generateSecretKey } from 'nostr-tools'; + +// Create signer with existing key +const signer = new SimpleSigner(secretKey); + +// Or generate new key +const newSecretKey = generateSecretKey(); +const newSigner = new SimpleSigner(newSecretKey); + +// Get public key +const pubkey = await signer.getPublicKey(); + +// Sign event +const unsignedEvent = { + kind: 1, + content: 'Hello Nostr!', + created_at: Math.floor(Date.now() / 1000), + tags: [] +}; + +const signedEvent = await signer.signEvent(unsignedEvent); +``` + +### NIP-04 Encryption + +```javascript +// Encrypt message +const ciphertext = await signer.nip04Encrypt( + recipientPubkey, + 'Secret message' +); + +// Decrypt message +const plaintext = await signer.nip04Decrypt( + senderPubkey, + ciphertext +); +``` + +### NIP-44 Encryption + +```javascript +// Encrypt with NIP-44 (preferred) +const ciphertext = await signer.nip44Encrypt( + recipientPubkey, + 'Secret message' +); + +// Decrypt +const plaintext = await signer.nip44Decrypt( + senderPubkey, + ciphertext +); +``` + +## NIP-07 Signer + +### Browser Extension Integration + +```javascript +import { Nip07Signer } from 'applesauce-signers'; + +// Check if extension is available +if (window.nostr) { + const signer = new Nip07Signer(); + + // Get public key (may prompt user) + const pubkey = await signer.getPublicKey(); + + // Sign event (prompts user) + const signedEvent = await signer.signEvent(unsignedEvent); +} +``` + +### Handling Extension Availability + +```javascript +function getAvailableSigner() { + if (typeof window !== 'undefined' && window.nostr) { + return new Nip07Signer(); + } + return null; +} + +// Wait for extension to load +async function waitForExtension(timeout = 3000) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (window.nostr) { + return new Nip07Signer(); + } + await new Promise(r => setTimeout(r, 100)); + } + + return null; +} +``` + +### Extension Permissions + +```javascript +// Some extensions support granular permissions +const signer = new Nip07Signer(); + +// Request specific permissions +try { + // This varies by extension + await window.nostr.enable(); +} catch (error) { + console.log('User denied permission'); +} +``` + +## NIP-46 Remote Signer + +### Nostr Connect + +```javascript +import { Nip46Signer } from 'applesauce-signers'; + +// Create remote signer +const signer = new Nip46Signer({ + // Remote signer's pubkey + remotePubkey: signerPubkey, + + // Relays for communication + relays: ['wss://relay.example.com'], + + // Local secret key for encryption + localSecretKey: localSecretKey, + + // Optional: custom client name + clientName: 'My Nostr App' +}); + +// Connect to remote signer +await signer.connect(); + +// Get public key +const pubkey = await signer.getPublicKey(); + +// Sign event +const signedEvent = await signer.signEvent(unsignedEvent); + +// Disconnect when done +signer.disconnect(); +``` + +### Connection URL + +```javascript +// Parse nostrconnect:// URL +function parseNostrConnectUrl(url) { + const parsed = new URL(url); + + return { + pubkey: parsed.pathname.replace('//', ''), + relay: parsed.searchParams.get('relay'), + secret: parsed.searchParams.get('secret') + }; +} + +// Create signer from URL +const { pubkey, relay, secret } = parseNostrConnectUrl(connectUrl); + +const signer = new Nip46Signer({ + remotePubkey: pubkey, + relays: [relay], + localSecretKey: generateSecretKey(), + secret: secret +}); +``` + +### Bunker URL + +```javascript +// Parse bunker:// URL (NIP-46) +function parseBunkerUrl(url) { + const parsed = new URL(url); + + return { + pubkey: parsed.pathname.replace('//', ''), + relays: parsed.searchParams.getAll('relay'), + secret: parsed.searchParams.get('secret') + }; +} + +const { pubkey, relays, secret } = parseBunkerUrl(bunkerUrl); +``` + +## Signer Management + +### Signer Store + +```javascript +import { SignerStore } from 'applesauce-signers'; + +const signerStore = new SignerStore(); + +// Set active signer +signerStore.setSigner(signer); + +// Get active signer +const activeSigner = signerStore.getSigner(); + +// Clear signer (logout) +signerStore.clearSigner(); + +// Observable for signer changes +signerStore.signer$.subscribe(signer => { + if (signer) { + console.log('Logged in'); + } else { + console.log('Logged out'); + } +}); +``` + +### Multi-Account Support + +```javascript +class AccountManager { + constructor() { + this.accounts = new Map(); + this.activeAccount = null; + } + + addAccount(pubkey, signer) { + this.accounts.set(pubkey, signer); + } + + removeAccount(pubkey) { + this.accounts.delete(pubkey); + if (this.activeAccount === pubkey) { + this.activeAccount = null; + } + } + + switchAccount(pubkey) { + if (this.accounts.has(pubkey)) { + this.activeAccount = pubkey; + return this.accounts.get(pubkey); + } + return null; + } + + getActiveSigner() { + return this.activeAccount + ? this.accounts.get(this.activeAccount) + : null; + } +} +``` + +## Custom Signers + +### Implementing a Custom Signer + +```javascript +class CustomSigner { + constructor(options) { + this.options = options; + } + + async getPublicKey() { + // Return public key + return this.options.pubkey; + } + + async signEvent(event) { + // Implement signing logic + // Could call external API, hardware wallet, etc. + + const signedEvent = await this.externalSign(event); + return signedEvent; + } + + async nip04Encrypt(pubkey, plaintext) { + // Implement NIP-04 encryption + throw new Error('NIP-04 not supported'); + } + + async nip04Decrypt(pubkey, ciphertext) { + throw new Error('NIP-04 not supported'); + } + + async nip44Encrypt(pubkey, plaintext) { + // Implement NIP-44 encryption + throw new Error('NIP-44 not supported'); + } + + async nip44Decrypt(pubkey, ciphertext) { + throw new Error('NIP-44 not supported'); + } +} +``` + +### Hardware Wallet Signer + +```javascript +class HardwareWalletSigner { + constructor(devicePath) { + this.devicePath = devicePath; + } + + async connect() { + // Connect to hardware device + this.device = await connectToDevice(this.devicePath); + } + + async getPublicKey() { + // Get public key from device + return await this.device.getNostrPubkey(); + } + + async signEvent(event) { + // Sign on device (user confirms on device) + const signature = await this.device.signNostrEvent(event); + + return { + ...event, + pubkey: await this.getPublicKey(), + id: getEventHash(event), + sig: signature + }; + } +} +``` + +### Read-Only Signer + +```javascript +class ReadOnlySigner { + constructor(pubkey) { + this.pubkey = pubkey; + } + + async getPublicKey() { + return this.pubkey; + } + + async signEvent(event) { + throw new Error('Read-only mode: cannot sign events'); + } + + async nip04Encrypt(pubkey, plaintext) { + throw new Error('Read-only mode: cannot encrypt'); + } + + async nip04Decrypt(pubkey, ciphertext) { + throw new Error('Read-only mode: cannot decrypt'); + } +} +``` + +## Signing Utilities + +### Event Creation Helper + +```javascript +async function createAndSignEvent(signer, template) { + const pubkey = await signer.getPublicKey(); + + const event = { + ...template, + pubkey, + created_at: template.created_at || Math.floor(Date.now() / 1000) + }; + + return await signer.signEvent(event); +} + +// Usage +const signedNote = await createAndSignEvent(signer, { + kind: 1, + content: 'Hello!', + tags: [] +}); +``` + +### Batch Signing + +```javascript +async function signEvents(signer, events) { + const signed = []; + + for (const event of events) { + const signedEvent = await signer.signEvent(event); + signed.push(signedEvent); + } + + return signed; +} + +// With parallelization (if signer supports) +async function signEventsParallel(signer, events) { + return Promise.all( + events.map(event => signer.signEvent(event)) + ); +} +``` + +## Svelte Integration + +### Signer Context + +```svelte + + + + +``` + +```svelte + + +``` + +### Login Component + +```svelte + + +{#if $signer} + +{:else} + + +
+ + +
+{/if} +``` + +## Best Practices + +### Security + +1. **Never store secret keys in plain text** - Use secure storage +2. **Prefer NIP-07** - Let extensions manage keys +3. **Clear keys on logout** - Don't leave in memory +4. **Validate before signing** - Check event content + +### User Experience + +1. **Show signing status** - Loading states +2. **Handle rejections gracefully** - User may cancel +3. **Provide fallbacks** - Multiple login options +4. **Remember preferences** - Store signer type + +### Error Handling + +```javascript +async function safeSign(signer, event) { + try { + return await signer.signEvent(event); + } catch (error) { + if (error.message.includes('rejected')) { + console.log('User rejected signing'); + return null; + } + if (error.message.includes('timeout')) { + console.log('Signing timed out'); + return null; + } + throw error; + } +} +``` + +### Permission Checking + +```javascript +function hasEncryptionSupport(signer) { + return typeof signer.nip04Encrypt === 'function' || + typeof signer.nip44Encrypt === 'function'; +} + +function getEncryptionMethod(signer) { + // Prefer NIP-44 + if (typeof signer.nip44Encrypt === 'function') { + return 'nip44'; + } + if (typeof signer.nip04Encrypt === 'function') { + return 'nip04'; + } + return null; +} +``` + +## Common Patterns + +### Signer Detection + +```javascript +async function detectSigners() { + const available = []; + + // Check NIP-07 + if (typeof window !== 'undefined' && window.nostr) { + available.push({ + type: 'nip07', + name: 'Browser Extension', + create: () => new Nip07Signer() + }); + } + + // Check stored credentials + const storedKey = localStorage.getItem('nsec'); + if (storedKey) { + available.push({ + type: 'stored', + name: 'Saved Key', + create: () => new SimpleSigner(storedKey) + }); + } + + return available; +} +``` + +### Auto-Reconnect for NIP-46 + +```javascript +class ReconnectingNip46Signer { + constructor(options) { + this.options = options; + this.signer = null; + } + + async connect() { + this.signer = new Nip46Signer(this.options); + await this.signer.connect(); + } + + async signEvent(event) { + try { + return await this.signer.signEvent(event); + } catch (error) { + if (error.message.includes('disconnected')) { + await this.connect(); + return await this.signer.signEvent(event); + } + throw error; + } + } +} +``` + +### Signer Type Persistence + +```javascript +const SIGNER_KEY = 'nostr_signer_type'; + +function saveSigner(type, data) { + localStorage.setItem(SIGNER_KEY, JSON.stringify({ type, data })); +} + +async function restoreSigner() { + const saved = localStorage.getItem(SIGNER_KEY); + if (!saved) return null; + + const { type, data } = JSON.parse(saved); + + switch (type) { + case 'nip07': + if (window.nostr) { + return new Nip07Signer(); + } + break; + case 'simple': + // Don't store secret keys! + break; + case 'nip46': + const signer = new Nip46Signer(data); + await signer.connect(); + return signer; + } + + return null; +} +``` + +## Troubleshooting + +### Common Issues + +**Extension not detected:** +- Wait for page load +- Check window.nostr exists +- Verify extension is enabled + +**Signing rejected:** +- User cancelled in extension +- Handle gracefully with error message + +**NIP-46 connection fails:** +- Check relay is accessible +- Verify remote signer is online +- Check secret matches + +**Encryption not supported:** +- Check signer has encrypt methods +- Fall back to alternative method +- Show user appropriate error + +## References + +- **applesauce GitHub**: https://github.com/hzrd149/applesauce +- **NIP-07 Specification**: https://github.com/nostr-protocol/nips/blob/master/07.md +- **NIP-46 Specification**: https://github.com/nostr-protocol/nips/blob/master/46.md +- **nostr-tools**: https://github.com/nbd-wtf/nostr-tools + +## Related Skills + +- **nostr-tools** - Event creation and signing utilities +- **applesauce-core** - Event stores and queries +- **nostr** - Nostr protocol fundamentals +- **svelte** - Building Nostr UIs diff --git a/.claude/skills/cypher/SKILL.md b/.claude/skills/cypher/SKILL.md new file mode 100644 index 00000000..e8ae91b2 --- /dev/null +++ b/.claude/skills/cypher/SKILL.md @@ -0,0 +1,395 @@ +--- +name: cypher +description: This skill should be used when writing, debugging, or discussing Neo4j Cypher queries. Provides comprehensive knowledge of Cypher syntax, query patterns, performance optimization, and common mistakes. Particularly useful for translating between domain models and graph queries. +--- + +# Neo4j Cypher Query Language + +## Purpose + +This skill provides expert-level guidance for writing Neo4j Cypher queries, including syntax, patterns, performance optimization, and common pitfalls. It is particularly tuned for the patterns used in this ORLY Nostr relay codebase. + +## When to Use + +Activate this skill when: +- Writing Cypher queries for Neo4j +- Debugging Cypher syntax errors +- Optimizing query performance +- Translating Nostr filter queries to Cypher +- Working with graph relationships and traversals +- Creating or modifying schema (indexes, constraints) + +## Core Cypher Syntax + +### Clause Order (CRITICAL) + +Cypher requires clauses in a specific order. Violating this causes syntax errors: + +```cypher +// CORRECT order of clauses +MATCH (n:Label) // 1. Pattern matching +WHERE n.prop = value // 2. Filtering +WITH n, count(*) AS cnt // 3. Intermediate results (resets scope) +OPTIONAL MATCH (n)-[r]-() // 4. Optional patterns +CREATE (m:NewNode) // 5. Node/relationship creation +SET n.prop = value // 6. Property updates +DELETE r // 7. Deletions +RETURN n.prop AS result // 8. Return clause +ORDER BY result DESC // 9. Ordering +SKIP 10 LIMIT 20 // 10. Pagination +``` + +### The WITH Clause (CRITICAL) + +The `WITH` clause is required to transition between certain operations: + +**Rule: Cannot use MATCH after CREATE without WITH** + +```cypher +// WRONG - MATCH after CREATE without WITH +CREATE (e:Event {id: $id}) +MATCH (ref:Event {id: $refId}) // ERROR! +CREATE (e)-[:REFERENCES]->(ref) + +// CORRECT - Use WITH to carry variables forward +CREATE (e:Event {id: $id}) +WITH e +MATCH (ref:Event {id: $refId}) +CREATE (e)-[:REFERENCES]->(ref) +``` + +**Rule: WITH resets the scope** + +Variables not included in WITH are no longer accessible: + +```cypher +// WRONG - 'a' is lost after WITH +MATCH (a:Author), (e:Event) +WITH e +WHERE a.pubkey = $pubkey // ERROR: 'a' not defined + +// CORRECT - Include all needed variables +MATCH (a:Author), (e:Event) +WITH a, e +WHERE a.pubkey = $pubkey +``` + +### Node and Relationship Patterns + +```cypher +// Nodes +(n) // Anonymous node +(n:Label) // Labeled node +(n:Label {prop: value}) // Node with properties +(n:Label:OtherLabel) // Multiple labels + +// Relationships +-[r]-> // Directed, anonymous +-[r:TYPE]-> // Typed relationship +-[r:TYPE {prop: value}]-> // With properties +-[r:TYPE|OTHER]-> // Multiple types (OR) +-[*1..3]-> // Variable length (1 to 3 hops) +-[*]-> // Any number of hops +``` + +### MERGE vs CREATE + +**CREATE**: Always creates new nodes/relationships (may create duplicates) + +```cypher +CREATE (n:Event {id: $id}) // Creates even if id exists +``` + +**MERGE**: Finds or creates (idempotent) + +```cypher +MERGE (n:Event {id: $id}) // Finds existing or creates new +ON CREATE SET n.created = timestamp() +ON MATCH SET n.accessed = timestamp() +``` + +**Best Practice**: Use MERGE for reference nodes, CREATE for unique events + +```cypher +// Reference nodes - use MERGE (idempotent) +MERGE (author:Author {pubkey: $pubkey}) + +// Unique events - use CREATE (after checking existence) +CREATE (e:Event {id: $eventId, ...}) +``` + +### OPTIONAL MATCH + +Returns NULL for non-matching patterns (like LEFT JOIN): + +```cypher +// Find events, with or without tags +MATCH (e:Event) +OPTIONAL MATCH (e)-[:TAGGED_WITH]->(t:Tag) +RETURN e.id, collect(t.value) AS tags +``` + +### Conditional Creation with FOREACH + +To conditionally create relationships: + +```cypher +// FOREACH trick for conditional operations +OPTIONAL MATCH (ref:Event {id: $refId}) +FOREACH (ignoreMe IN CASE WHEN ref IS NOT NULL THEN [1] ELSE [] END | + CREATE (e)-[:REFERENCES]->(ref) +) +``` + +### Aggregation Functions + +```cypher +count(*) // Count all rows +count(n) // Count non-null values +count(DISTINCT n) // Count unique values +collect(n) // Collect into list +collect(DISTINCT n) // Collect unique values +sum(n.value) // Sum values +avg(n.value) // Average +min(n.value), max(n.value) // Min/max +``` + +### String Operations + +```cypher +// String matching +WHERE n.name STARTS WITH 'prefix' +WHERE n.name ENDS WITH 'suffix' +WHERE n.name CONTAINS 'substring' +WHERE n.name =~ 'regex.*pattern' // Regex + +// String functions +toLower(str), toUpper(str) +trim(str), ltrim(str), rtrim(str) +substring(str, start, length) +replace(str, search, replacement) +``` + +### List Operations + +```cypher +// IN clause +WHERE n.kind IN [1, 7, 30023] +WHERE n.pubkey IN $pubkeyList + +// List comprehension +[x IN list WHERE x > 0 | x * 2] + +// UNWIND - expand list into rows +UNWIND $pubkeys AS pubkey +MERGE (u:User {pubkey: pubkey}) +``` + +### Parameters + +Always use parameters for values (security + performance): + +```cypher +// CORRECT - parameterized +MATCH (e:Event {id: $eventId}) +WHERE e.kind IN $kinds + +// WRONG - string interpolation (SQL injection risk!) +MATCH (e:Event {id: '" + eventId + "'}) +``` + +## Schema Management + +### Constraints + +```cypher +// Uniqueness constraint (also creates index) +CREATE CONSTRAINT event_id_unique IF NOT EXISTS +FOR (e:Event) REQUIRE e.id IS UNIQUE + +// Composite uniqueness +CREATE CONSTRAINT card_unique IF NOT EXISTS +FOR (c:Card) REQUIRE (c.customer_id, c.observee_pubkey) IS UNIQUE + +// Drop constraint +DROP CONSTRAINT event_id_unique IF EXISTS +``` + +### Indexes + +```cypher +// Single property index +CREATE INDEX event_kind IF NOT EXISTS FOR (e:Event) ON (e.kind) + +// Composite index +CREATE INDEX event_kind_created IF NOT EXISTS +FOR (e:Event) ON (e.kind, e.created_at) + +// Drop index +DROP INDEX event_kind IF EXISTS +``` + +## Common Query Patterns + +### Find with Filter + +```cypher +// Multiple conditions with OR +MATCH (e:Event) +WHERE e.kind IN $kinds + AND (e.id = $id1 OR e.id = $id2) + AND e.created_at >= $since +RETURN e +ORDER BY e.created_at DESC +LIMIT $limit +``` + +### Graph Traversal + +```cypher +// Find events by author +MATCH (e:Event)-[:AUTHORED_BY]->(a:Author {pubkey: $pubkey}) +RETURN e + +// Find followers of a user +MATCH (follower:NostrUser)-[:FOLLOWS]->(user:NostrUser {pubkey: $pubkey}) +RETURN follower.pubkey + +// Find mutual follows (friends) +MATCH (a:NostrUser {pubkey: $pubkeyA})-[:FOLLOWS]->(b:NostrUser) +WHERE (b)-[:FOLLOWS]->(a) +RETURN b.pubkey AS mutual_friend +``` + +### Upsert Pattern + +```cypher +MERGE (n:Node {key: $key}) +ON CREATE SET + n.created_at = timestamp(), + n.value = $value +ON MATCH SET + n.updated_at = timestamp(), + n.value = $value +RETURN n +``` + +### Batch Processing with UNWIND + +```cypher +// Create multiple nodes from list +UNWIND $items AS item +CREATE (n:Node {id: item.id, value: item.value}) + +// Create relationships from list +UNWIND $follows AS followed_pubkey +MERGE (followed:NostrUser {pubkey: followed_pubkey}) +MERGE (author)-[:FOLLOWS]->(followed) +``` + +## Performance Optimization + +### Index Usage + +1. **Start with indexed properties** - Begin MATCH with most selective indexed field +2. **Use composite indexes** - For queries filtering on multiple properties +3. **Profile queries** - Use `PROFILE` prefix to see execution plan + +```cypher +PROFILE MATCH (e:Event {kind: 1}) +WHERE e.created_at > $since +RETURN e LIMIT 100 +``` + +### Query Optimization Tips + +1. **Filter early** - Put WHERE conditions close to MATCH +2. **Limit early** - Use LIMIT as early as possible +3. **Avoid Cartesian products** - Connect patterns or use WITH +4. **Use parameters** - Enables query plan caching + +```cypher +// GOOD - Filter and limit early +MATCH (e:Event) +WHERE e.kind IN $kinds AND e.created_at >= $since +WITH e ORDER BY e.created_at DESC LIMIT 100 +OPTIONAL MATCH (e)-[:TAGGED_WITH]->(t:Tag) +RETURN e, collect(t) + +// BAD - Late filtering +MATCH (e:Event), (t:Tag) +WHERE e.kind IN $kinds +RETURN e, t LIMIT 100 +``` + +## Reference Materials + +For detailed information, consult the reference files: + +- **references/syntax-reference.md** - Complete Cypher syntax guide with all clause types, operators, and functions +- **references/common-patterns.md** - Project-specific patterns for ORLY Nostr relay including event storage, tag queries, and social graph traversals +- **references/common-mistakes.md** - Frequent Cypher errors and how to avoid them + +## ORLY-Specific Patterns + +This codebase uses these specific Cypher patterns: + +### Event Storage Pattern + +```cypher +// Create event with author relationship +MERGE (a:Author {pubkey: $pubkey}) +CREATE (e:Event { + id: $eventId, + serial: $serial, + kind: $kind, + created_at: $createdAt, + content: $content, + sig: $sig, + pubkey: $pubkey, + tags: $tags +}) +CREATE (e)-[:AUTHORED_BY]->(a) +``` + +### Tag Query Pattern + +```cypher +// Query events by tag (Nostr # filter) +MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: $tagType}) +WHERE t.value IN $tagValues +RETURN e +ORDER BY e.created_at DESC +LIMIT $limit +``` + +### Social Graph Pattern + +```cypher +// Process contact list with diff-based updates +// Mark old as superseded +OPTIONAL MATCH (old:ProcessedSocialEvent {event_id: $old_event_id}) +SET old.superseded_by = $new_event_id + +// Create tracking node +CREATE (new:ProcessedSocialEvent { + event_id: $new_event_id, + event_kind: 3, + pubkey: $author_pubkey, + created_at: $created_at, + processed_at: timestamp() +}) + +// Update relationships +MERGE (author:NostrUser {pubkey: $author_pubkey}) +WITH author +UNWIND $added_follows AS followed_pubkey +MERGE (followed:NostrUser {pubkey: followed_pubkey}) +MERGE (author)-[:FOLLOWS]->(followed) +``` + +## Official Resources + +- Neo4j Cypher Manual: https://neo4j.com/docs/cypher-manual/current/ +- Cypher Cheat Sheet: https://neo4j.com/docs/cypher-cheat-sheet/current/ +- Query Tuning: https://neo4j.com/docs/cypher-manual/current/query-tuning/ \ No newline at end of file diff --git a/.claude/skills/cypher/references/common-mistakes.md b/.claude/skills/cypher/references/common-mistakes.md new file mode 100644 index 00000000..a61efe97 --- /dev/null +++ b/.claude/skills/cypher/references/common-mistakes.md @@ -0,0 +1,381 @@ +# Common Cypher Mistakes and How to Avoid Them + +## Clause Ordering Errors + +### MATCH After CREATE Without WITH + +**Error**: `Invalid input 'MATCH': expected ... WITH` + +```cypher +// WRONG +CREATE (e:Event {id: $id}) +MATCH (ref:Event {id: $refId}) // ERROR! +CREATE (e)-[:REFERENCES]->(ref) + +// CORRECT - Use WITH to transition +CREATE (e:Event {id: $id}) +WITH e +MATCH (ref:Event {id: $refId}) +CREATE (e)-[:REFERENCES]->(ref) +``` + +**Rule**: After CREATE, you must use WITH before MATCH. + +### WHERE After WITH Without Carrying Variables + +**Error**: `Variable 'x' not defined` + +```cypher +// WRONG - 'a' is lost +MATCH (a:Author), (e:Event) +WITH e +WHERE a.pubkey = $pubkey // ERROR: 'a' not in scope + +// CORRECT - Include all needed variables +MATCH (a:Author), (e:Event) +WITH a, e +WHERE a.pubkey = $pubkey +``` + +**Rule**: WITH resets the scope. Include all variables you need. + +### ORDER BY Without Aliased Return + +**Error**: `Invalid input 'ORDER': expected ... AS` + +```cypher +// WRONG in some contexts +RETURN n.name +ORDER BY n.name + +// SAFER - Use alias +RETURN n.name AS name +ORDER BY name +``` + +## MERGE Mistakes + +### MERGE on Complex Pattern Creates Duplicates + +```cypher +// DANGEROUS - May create duplicate nodes +MERGE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'}) + +// CORRECT - MERGE nodes separately first +MERGE (a:Person {name: 'Alice'}) +MERGE (b:Person {name: 'Bob'}) +MERGE (a)-[:KNOWS]->(b) +``` + +**Rule**: MERGE simple patterns, not complex ones. + +### MERGE Without Unique Property + +```cypher +// DANGEROUS - Will keep creating nodes +MERGE (p:Person) // No unique identifier! +SET p.name = 'Alice' + +// CORRECT - Provide unique key +MERGE (p:Person {email: $email}) +SET p.name = 'Alice' +``` + +**Rule**: MERGE must have properties that uniquely identify the node. + +### Missing ON CREATE/ON MATCH + +```cypher +// LOSES context of whether new or existing +MERGE (p:Person {id: $id}) +SET p.updated_at = timestamp() // Always runs + +// BETTER - Handle each case +MERGE (p:Person {id: $id}) +ON CREATE SET p.created_at = timestamp() +ON MATCH SET p.updated_at = timestamp() +``` + +## NULL Handling Errors + +### Comparing with NULL + +```cypher +// WRONG - NULL = NULL is NULL, not true +WHERE n.email = null // Never matches! + +// CORRECT +WHERE n.email IS NULL +WHERE n.email IS NOT NULL +``` + +### NULL in Aggregations + +```cypher +// count(NULL) returns 0, collect(NULL) includes NULL +MATCH (n:Person) +OPTIONAL MATCH (n)-[:BOUGHT]->(p:Product) +RETURN n.name, count(p) // count ignores NULL +``` + +### NULL Propagation in Expressions + +```cypher +// Any operation with NULL returns NULL +WHERE n.age + 1 > 21 // If n.age is NULL, whole expression is NULL (falsy) + +// Handle with coalesce +WHERE coalesce(n.age, 0) + 1 > 21 +``` + +## List and IN Clause Errors + +### Empty List in IN + +```cypher +// An empty list never matches +WHERE n.kind IN [] // Always false + +// Check for empty list in application code before query +// Or use CASE: +WHERE CASE WHEN size($kinds) > 0 THEN n.kind IN $kinds ELSE true END +``` + +### IN with NULL Values + +```cypher +// NULL in the list causes issues +WHERE n.id IN [1, NULL, 3] // NULL is never equal to anything + +// Filter NULLs in application code +``` + +## Relationship Pattern Errors + +### Forgetting Direction + +```cypher +// WRONG - Creates both directions +MATCH (a)-[:FOLLOWS]-(b) // Undirected! + +// CORRECT - Specify direction +MATCH (a)-[:FOLLOWS]->(b) // a follows b +MATCH (a)<-[:FOLLOWS]-(b) // b follows a +``` + +### Variable-Length Without Bounds + +```cypher +// DANGEROUS - Potentially explosive +MATCH (a)-[*]->(b) // Any length path! + +// SAFE - Set bounds +MATCH (a)-[*1..3]->(b) // 1 to 3 hops max +``` + +### Creating Duplicate Relationships + +```cypher +// May create duplicates +CREATE (a)-[:KNOWS]->(b) + +// Idempotent +MERGE (a)-[:KNOWS]->(b) +``` + +## Performance Mistakes + +### Cartesian Products + +```cypher +// WRONG - Cartesian product +MATCH (a:Person), (b:Product) +WHERE a.id = $personId AND b.id = $productId +CREATE (a)-[:BOUGHT]->(b) + +// CORRECT - Single pattern or sequential +MATCH (a:Person {id: $personId}) +MATCH (b:Product {id: $productId}) +CREATE (a)-[:BOUGHT]->(b) +``` + +### Late Filtering + +```cypher +// SLOW - Filters after collecting everything +MATCH (e:Event) +WITH e +WHERE e.kind = 1 // Should be in MATCH or right after + +// FAST - Filter early +MATCH (e:Event) +WHERE e.kind = 1 +``` + +### Missing LIMIT with ORDER BY + +```cypher +// SLOW - Sorts all results +MATCH (e:Event) +RETURN e +ORDER BY e.created_at DESC + +// FAST - Limits result set +MATCH (e:Event) +RETURN e +ORDER BY e.created_at DESC +LIMIT 100 +``` + +### Unparameterized Queries + +```cypher +// WRONG - No query plan caching, injection risk +MATCH (e:Event {id: '" + eventId + "'}) + +// CORRECT - Use parameters +MATCH (e:Event {id: $eventId}) +``` + +## String Comparison Errors + +### Case Sensitivity + +```cypher +// Cypher strings are case-sensitive +WHERE n.name = 'alice' // Won't match 'Alice' + +// Use toLower/toUpper for case-insensitive +WHERE toLower(n.name) = toLower($name) + +// Or use regex with (?i) +WHERE n.name =~ '(?i)alice' +``` + +### LIKE vs CONTAINS + +```cypher +// There's no LIKE in Cypher +WHERE n.name LIKE '%alice%' // ERROR! + +// Use CONTAINS, STARTS WITH, ENDS WITH +WHERE n.name CONTAINS 'alice' +WHERE n.name STARTS WITH 'ali' +WHERE n.name ENDS WITH 'ice' + +// Or regex for complex patterns +WHERE n.name =~ '.*ali.*ce.*' +``` + +## Index Mistakes + +### Constraint vs Index + +```cypher +// Constraint (also creates index, enforces uniqueness) +CREATE CONSTRAINT foo IF NOT EXISTS FOR (n:Node) REQUIRE n.id IS UNIQUE + +// Index only (no uniqueness enforcement) +CREATE INDEX bar IF NOT EXISTS FOR (n:Node) ON (n.id) +``` + +### Index Not Used + +```cypher +// Index on n.id won't help here +WHERE toLower(n.id) = $id // Function applied to indexed property! + +// Store lowercase if needed, or create computed property +``` + +### Wrong Composite Index Order + +```cypher +// Index on (kind, created_at) won't help query by created_at alone +MATCH (e:Event) WHERE e.created_at > $since // Index not used + +// Either create single-property index or query by kind too +CREATE INDEX event_created_at FOR (e:Event) ON (e.created_at) +``` + +## Transaction Errors + +### Read After Write in Same Transaction + +```cypher +// In Neo4j, reads in a transaction see the writes +// But be careful with external processes +CREATE (n:Node {id: 'new'}) +WITH n +MATCH (m:Node {id: 'new'}) // Will find 'n' +``` + +### Locks and Deadlocks + +```cypher +// MERGE takes locks; avoid complex patterns that might deadlock +// Bad: two MERGEs on same labels in different order +Session 1: MERGE (a:Person {id: 1}) MERGE (b:Person {id: 2}) +Session 2: MERGE (b:Person {id: 2}) MERGE (a:Person {id: 1}) // Potential deadlock + +// Good: consistent ordering +Session 1: MERGE (a:Person {id: 1}) MERGE (b:Person {id: 2}) +Session 2: MERGE (a:Person {id: 1}) MERGE (b:Person {id: 2}) +``` + +## Type Coercion Issues + +### Integer vs String + +```cypher +// Types must match +WHERE n.id = 123 // Won't match if n.id is "123" +WHERE n.id = '123' // Won't match if n.id is 123 + +// Use appropriate parameter types from Go +params["id"] = int64(123) // For integer +params["id"] = "123" // For string +``` + +### Boolean Handling + +```cypher +// Neo4j booleans vs strings +WHERE n.active = true // Boolean +WHERE n.active = 'true' // String - different! +``` + +## Delete Errors + +### Delete Node With Relationships + +```cypher +// ERROR - Node still has relationships +MATCH (n:Person {id: $id}) +DELETE n + +// CORRECT - Delete relationships first +MATCH (n:Person {id: $id}) +DETACH DELETE n +``` + +### Optional Match and Delete + +```cypher +// WRONG - DELETE NULL causes no error but also doesn't help +OPTIONAL MATCH (n:Node {id: $id}) +DELETE n // If n is NULL, nothing happens silently + +// Better - Check existence first or handle in application +MATCH (n:Node {id: $id}) +DELETE n +``` + +## Debugging Tips + +1. **Use EXPLAIN** to see query plan without executing +2. **Use PROFILE** to see actual execution metrics +3. **Break complex queries** into smaller parts to isolate issues +4. **Check parameter types** - mismatched types are a common issue +5. **Verify indexes exist** with `SHOW INDEXES` +6. **Check constraints** with `SHOW CONSTRAINTS` diff --git a/.claude/skills/cypher/references/common-patterns.md b/.claude/skills/cypher/references/common-patterns.md new file mode 100644 index 00000000..5a53ee91 --- /dev/null +++ b/.claude/skills/cypher/references/common-patterns.md @@ -0,0 +1,397 @@ +# Common Cypher Patterns for ORLY Nostr Relay + +This reference contains project-specific Cypher patterns used in the ORLY Nostr relay's Neo4j backend. + +## Schema Overview + +### Node Types + +| Label | Purpose | Key Properties | +|-------|---------|----------------| +| `Event` | Nostr events (NIP-01) | `id`, `kind`, `pubkey`, `created_at`, `content`, `sig`, `tags`, `serial` | +| `Author` | Event authors (for NIP-01 queries) | `pubkey` | +| `Tag` | Generic tags | `type`, `value` | +| `NostrUser` | Social graph users (WoT) | `pubkey`, `name`, `about`, `picture`, `nip05` | +| `ProcessedSocialEvent` | Social event tracking | `event_id`, `event_kind`, `pubkey`, `superseded_by` | +| `Marker` | Internal state markers | `key`, `value` | + +### Relationship Types + +| Type | From | To | Purpose | +|------|------|-----|---------| +| `AUTHORED_BY` | Event | Author | Links event to author | +| `TAGGED_WITH` | Event | Tag | Links event to tags | +| `REFERENCES` | Event | Event | e-tag references | +| `MENTIONS` | Event | Author | p-tag mentions | +| `FOLLOWS` | NostrUser | NostrUser | Contact list (kind 3) | +| `MUTES` | NostrUser | NostrUser | Mute list (kind 10000) | +| `REPORTS` | NostrUser | NostrUser | Reports (kind 1984) | + +## Event Storage Patterns + +### Create Event with Full Relationships + +This pattern creates an event and all related nodes/relationships atomically: + +```cypher +// 1. Create or get author +MERGE (a:Author {pubkey: $pubkey}) + +// 2. Create event node +CREATE (e:Event { + id: $eventId, + serial: $serial, + kind: $kind, + created_at: $createdAt, + content: $content, + sig: $sig, + pubkey: $pubkey, + tags: $tagsJson // JSON string for full tag data +}) + +// 3. Link to author +CREATE (e)-[:AUTHORED_BY]->(a) + +// 4. Process e-tags (event references) +WITH e, a +OPTIONAL MATCH (ref0:Event {id: $eTag_0}) +FOREACH (_ IN CASE WHEN ref0 IS NOT NULL THEN [1] ELSE [] END | + CREATE (e)-[:REFERENCES]->(ref0) +) + +// 5. Process p-tags (mentions) +WITH e, a +MERGE (mentioned0:Author {pubkey: $pTag_0}) +CREATE (e)-[:MENTIONS]->(mentioned0) + +// 6. Process other tags +WITH e, a +MERGE (tag0:Tag {type: $tagType_0, value: $tagValue_0}) +CREATE (e)-[:TAGGED_WITH]->(tag0) + +RETURN e.id AS id +``` + +### Check Event Existence + +```cypher +MATCH (e:Event {id: $id}) +RETURN e.id AS id +LIMIT 1 +``` + +### Get Next Serial Number + +```cypher +MERGE (m:Marker {key: 'serial'}) +ON CREATE SET m.value = 1 +ON MATCH SET m.value = m.value + 1 +RETURN m.value AS serial +``` + +## Query Patterns + +### Basic Filter Query (NIP-01) + +```cypher +MATCH (e:Event) +WHERE e.kind IN $kinds + AND e.pubkey IN $authors + AND e.created_at >= $since + AND e.created_at <= $until +RETURN e.id AS id, + e.kind AS kind, + e.created_at AS created_at, + e.content AS content, + e.sig AS sig, + e.pubkey AS pubkey, + e.tags AS tags, + e.serial AS serial +ORDER BY e.created_at DESC +LIMIT $limit +``` + +### Query by Event ID (with prefix support) + +```cypher +// Exact match +MATCH (e:Event {id: $id}) +RETURN e + +// Prefix match +MATCH (e:Event) +WHERE e.id STARTS WITH $idPrefix +RETURN e +``` + +### Query by Tag (# filter) + +```cypher +MATCH (e:Event) +OPTIONAL MATCH (e)-[:TAGGED_WITH]->(t:Tag) +WHERE t.type = $tagType AND t.value IN $tagValues +RETURN DISTINCT e +ORDER BY e.created_at DESC +LIMIT $limit +``` + +### Count Events + +```cypher +MATCH (e:Event) +WHERE e.kind IN $kinds +RETURN count(e) AS count +``` + +### Query Delete Events Targeting an Event + +```cypher +MATCH (target:Event {id: $targetId}) +MATCH (e:Event {kind: 5})-[:REFERENCES]->(target) +RETURN e +ORDER BY e.created_at DESC +``` + +### Replaceable Event Check (kinds 0, 3, 10000-19999) + +```cypher +MATCH (e:Event {kind: $kind, pubkey: $pubkey}) +WHERE e.created_at < $newCreatedAt +RETURN e.serial AS serial +ORDER BY e.created_at DESC +``` + +### Parameterized Replaceable Event Check (kinds 30000-39999) + +```cypher +MATCH (e:Event {kind: $kind, pubkey: $pubkey})-[:TAGGED_WITH]->(t:Tag {type: 'd', value: $dValue}) +WHERE e.created_at < $newCreatedAt +RETURN e.serial AS serial +ORDER BY e.created_at DESC +``` + +## Social Graph Patterns + +### Update Profile (Kind 0) + +```cypher +MERGE (user:NostrUser {pubkey: $pubkey}) +ON CREATE SET + user.created_at = timestamp(), + user.first_seen_event = $event_id +ON MATCH SET + user.last_profile_update = $created_at +SET + user.name = $name, + user.about = $about, + user.picture = $picture, + user.nip05 = $nip05, + user.lud16 = $lud16, + user.display_name = $display_name +``` + +### Contact List Update (Kind 3) - Diff-Based + +```cypher +// Mark old event as superseded +OPTIONAL MATCH (old:ProcessedSocialEvent {event_id: $old_event_id}) +SET old.superseded_by = $new_event_id + +// Create new event tracking +CREATE (new:ProcessedSocialEvent { + event_id: $new_event_id, + event_kind: 3, + pubkey: $author_pubkey, + created_at: $created_at, + processed_at: timestamp(), + relationship_count: $total_follows, + superseded_by: null +}) + +// Get or create author +MERGE (author:NostrUser {pubkey: $author_pubkey}) + +// Update unchanged relationships to new event +WITH author +OPTIONAL MATCH (author)-[unchanged:FOLLOWS]->(followed:NostrUser) +WHERE unchanged.created_by_event = $old_event_id + AND NOT followed.pubkey IN $removed_follows +SET unchanged.created_by_event = $new_event_id, + unchanged.created_at = $created_at + +// Remove old relationships for removed follows +WITH author +OPTIONAL MATCH (author)-[old_follows:FOLLOWS]->(followed:NostrUser) +WHERE old_follows.created_by_event = $old_event_id + AND followed.pubkey IN $removed_follows +DELETE old_follows + +// Create new relationships for added follows +WITH author +UNWIND $added_follows AS followed_pubkey +MERGE (followed:NostrUser {pubkey: followed_pubkey}) +MERGE (author)-[new_follows:FOLLOWS]->(followed) +ON CREATE SET + new_follows.created_by_event = $new_event_id, + new_follows.created_at = $created_at, + new_follows.relay_received_at = timestamp() +ON MATCH SET + new_follows.created_by_event = $new_event_id, + new_follows.created_at = $created_at +``` + +### Create Report (Kind 1984) + +```cypher +// Create tracking node +CREATE (evt:ProcessedSocialEvent { + event_id: $event_id, + event_kind: 1984, + pubkey: $reporter_pubkey, + created_at: $created_at, + processed_at: timestamp(), + relationship_count: 1, + superseded_by: null +}) + +// Create users and relationship +MERGE (reporter:NostrUser {pubkey: $reporter_pubkey}) +MERGE (reported:NostrUser {pubkey: $reported_pubkey}) +CREATE (reporter)-[:REPORTS { + created_by_event: $event_id, + created_at: $created_at, + relay_received_at: timestamp(), + report_type: $report_type +}]->(reported) +``` + +### Get Latest Social Event for Pubkey + +```cypher +MATCH (evt:ProcessedSocialEvent {pubkey: $pubkey, event_kind: $kind}) +WHERE evt.superseded_by IS NULL +RETURN evt.event_id AS event_id, + evt.created_at AS created_at, + evt.relationship_count AS relationship_count +ORDER BY evt.created_at DESC +LIMIT 1 +``` + +### Get Follows for Event + +```cypher +MATCH (author:NostrUser)-[f:FOLLOWS]->(followed:NostrUser) +WHERE f.created_by_event = $event_id +RETURN collect(followed.pubkey) AS pubkeys +``` + +## WoT Query Patterns + +### Find Mutual Follows + +```cypher +MATCH (a:NostrUser {pubkey: $pubkeyA})-[:FOLLOWS]->(b:NostrUser) +WHERE (b)-[:FOLLOWS]->(a) +RETURN b.pubkey AS mutual_friend +``` + +### Find Followers + +```cypher +MATCH (follower:NostrUser)-[:FOLLOWS]->(user:NostrUser {pubkey: $pubkey}) +RETURN follower.pubkey, follower.name +``` + +### Find Following + +```cypher +MATCH (user:NostrUser {pubkey: $pubkey})-[:FOLLOWS]->(following:NostrUser) +RETURN following.pubkey, following.name +``` + +### Hop Distance (Trust Path) + +```cypher +MATCH (start:NostrUser {pubkey: $startPubkey}) +MATCH (end:NostrUser {pubkey: $endPubkey}) +MATCH path = shortestPath((start)-[:FOLLOWS*..6]->(end)) +RETURN length(path) AS hops, [n IN nodes(path) | n.pubkey] AS path +``` + +### Second-Degree Connections + +```cypher +MATCH (me:NostrUser {pubkey: $myPubkey})-[:FOLLOWS]->(:NostrUser)-[:FOLLOWS]->(suggested:NostrUser) +WHERE NOT (me)-[:FOLLOWS]->(suggested) + AND suggested.pubkey <> $myPubkey +RETURN suggested.pubkey, count(*) AS commonFollows +ORDER BY commonFollows DESC +LIMIT 20 +``` + +## Schema Management Patterns + +### Create Constraint + +```cypher +CREATE CONSTRAINT event_id_unique IF NOT EXISTS +FOR (e:Event) REQUIRE e.id IS UNIQUE +``` + +### Create Index + +```cypher +CREATE INDEX event_kind IF NOT EXISTS +FOR (e:Event) ON (e.kind) +``` + +### Create Composite Index + +```cypher +CREATE INDEX event_kind_created_at IF NOT EXISTS +FOR (e:Event) ON (e.kind, e.created_at) +``` + +### Drop All Data (Testing Only) + +```cypher +MATCH (n) DETACH DELETE n +``` + +## Performance Patterns + +### Use EXPLAIN/PROFILE + +```cypher +// See query plan without running +EXPLAIN MATCH (e:Event) WHERE e.kind = 1 RETURN e + +// Run and see actual metrics +PROFILE MATCH (e:Event) WHERE e.kind = 1 RETURN e +``` + +### Batch Import with UNWIND + +```cypher +UNWIND $events AS evt +CREATE (e:Event { + id: evt.id, + kind: evt.kind, + pubkey: evt.pubkey, + created_at: evt.created_at, + content: evt.content, + sig: evt.sig, + tags: evt.tags +}) +``` + +### Efficient Pagination + +```cypher +// Use indexed ORDER BY with WHERE for cursor-based pagination +MATCH (e:Event) +WHERE e.kind = 1 AND e.created_at < $cursor +RETURN e +ORDER BY e.created_at DESC +LIMIT 20 +``` diff --git a/.claude/skills/cypher/references/syntax-reference.md b/.claude/skills/cypher/references/syntax-reference.md new file mode 100644 index 00000000..ebb94b2b --- /dev/null +++ b/.claude/skills/cypher/references/syntax-reference.md @@ -0,0 +1,540 @@ +# Cypher Syntax Reference + +Complete syntax reference for Neo4j Cypher query language. + +## Clause Reference + +### Reading Clauses + +#### MATCH + +Finds patterns in the graph. + +```cypher +// Basic node match +MATCH (n:Label) + +// Match with properties +MATCH (n:Label {key: value}) + +// Match relationships +MATCH (a)-[r:RELATES_TO]->(b) + +// Match path +MATCH path = (a)-[*1..3]->(b) +``` + +#### OPTIONAL MATCH + +Like MATCH but returns NULL for non-matches (LEFT OUTER JOIN). + +```cypher +MATCH (a:Person) +OPTIONAL MATCH (a)-[:KNOWS]->(b:Person) +RETURN a.name, b.name // b.name may be NULL +``` + +#### WHERE + +Filters results. + +```cypher +// Comparison operators +WHERE n.age > 21 +WHERE n.age >= 21 +WHERE n.age < 65 +WHERE n.age <= 65 +WHERE n.name = 'Alice' +WHERE n.name <> 'Bob' + +// Boolean operators +WHERE n.age > 21 AND n.active = true +WHERE n.age < 18 OR n.age > 65 +WHERE NOT n.deleted + +// NULL checks +WHERE n.email IS NULL +WHERE n.email IS NOT NULL + +// Pattern predicates +WHERE (n)-[:KNOWS]->(:Person) +WHERE NOT (n)-[:BLOCKED]->() +WHERE exists((n)-[:FOLLOWS]->()) + +// String predicates +WHERE n.name STARTS WITH 'A' +WHERE n.name ENDS WITH 'son' +WHERE n.name CONTAINS 'li' +WHERE n.name =~ '(?i)alice.*' // Case-insensitive regex + +// List predicates +WHERE n.status IN ['active', 'pending'] +WHERE any(x IN n.tags WHERE x = 'important') +WHERE all(x IN n.scores WHERE x > 50) +WHERE none(x IN n.errors WHERE x IS NOT NULL) +WHERE single(x IN n.items WHERE x.primary = true) +``` + +### Writing Clauses + +#### CREATE + +Creates nodes and relationships. + +```cypher +// Create node +CREATE (n:Label {key: value}) + +// Create multiple nodes +CREATE (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'}) + +// Create relationship +CREATE (a)-[r:KNOWS {since: 2020}]->(b) + +// Create path +CREATE p = (a)-[:KNOWS]->(b)-[:KNOWS]->(c) +``` + +#### MERGE + +Find or create pattern. **Critical for idempotency**. + +```cypher +// MERGE node +MERGE (n:Label {key: $uniqueKey}) + +// MERGE with ON CREATE / ON MATCH +MERGE (n:Person {email: $email}) +ON CREATE SET n.created = timestamp(), n.name = $name +ON MATCH SET n.accessed = timestamp() + +// MERGE relationship (both nodes must exist or be in scope) +MERGE (a)-[r:KNOWS]->(b) +ON CREATE SET r.since = date() +``` + +**MERGE Gotcha**: MERGE on a pattern locks the entire pattern. For relationships, MERGE each node first: + +```cypher +// CORRECT +MERGE (a:Person {id: $id1}) +MERGE (b:Person {id: $id2}) +MERGE (a)-[:KNOWS]->(b) + +// RISKY - may create duplicate nodes +MERGE (a:Person {id: $id1})-[:KNOWS]->(b:Person {id: $id2}) +``` + +#### SET + +Updates properties. + +```cypher +// Set single property +SET n.name = 'Alice' + +// Set multiple properties +SET n.name = 'Alice', n.age = 30 + +// Set from map (replaces all properties) +SET n = {name: 'Alice', age: 30} + +// Set from map (adds/updates, keeps existing) +SET n += {name: 'Alice'} + +// Set label +SET n:NewLabel + +// Remove property +SET n.obsolete = null +``` + +#### DELETE / DETACH DELETE + +Removes nodes and relationships. + +```cypher +// Delete relationship +MATCH (a)-[r:KNOWS]->(b) +DELETE r + +// Delete node (must have no relationships) +MATCH (n:Orphan) +DELETE n + +// Delete node and all relationships +MATCH (n:Person {name: 'Bob'}) +DETACH DELETE n +``` + +#### REMOVE + +Removes properties and labels. + +```cypher +// Remove property +REMOVE n.temporary + +// Remove label +REMOVE n:OldLabel +``` + +### Projection Clauses + +#### RETURN + +Specifies output. + +```cypher +// Return nodes +RETURN n + +// Return properties +RETURN n.name, n.age + +// Return with alias +RETURN n.name AS name, n.age AS age + +// Return all +RETURN * + +// Return distinct +RETURN DISTINCT n.category + +// Return expression +RETURN n.price * n.quantity AS total +``` + +#### WITH + +Passes results between query parts. **Critical for multi-part queries**. + +```cypher +// Filter and pass +MATCH (n:Person) +WITH n WHERE n.age > 21 +RETURN n + +// Aggregate and continue +MATCH (n:Person)-[:BOUGHT]->(p:Product) +WITH n, count(p) AS purchases +WHERE purchases > 5 +RETURN n.name, purchases + +// Order and limit mid-query +MATCH (n:Person) +WITH n ORDER BY n.age DESC LIMIT 10 +MATCH (n)-[:LIVES_IN]->(c:City) +RETURN n.name, c.name +``` + +**WITH resets scope**: Variables not listed in WITH are no longer available. + +#### ORDER BY + +Sorts results. + +```cypher +ORDER BY n.name // Ascending (default) +ORDER BY n.name ASC // Explicit ascending +ORDER BY n.name DESC // Descending +ORDER BY n.lastName, n.firstName // Multiple fields +ORDER BY n.priority DESC, n.name // Mixed +``` + +#### SKIP and LIMIT + +Pagination. + +```cypher +// Skip first 10 +SKIP 10 + +// Return only 20 +LIMIT 20 + +// Pagination +ORDER BY n.created_at DESC +SKIP $offset LIMIT $pageSize +``` + +### Sub-queries + +#### CALL (Subquery) + +Execute subquery for each row. + +```cypher +MATCH (p:Person) +CALL { + WITH p + MATCH (p)-[:BOUGHT]->(prod:Product) + RETURN count(prod) AS purchaseCount +} +RETURN p.name, purchaseCount +``` + +#### UNION + +Combine results from multiple queries. + +```cypher +MATCH (n:Person) RETURN n.name AS name +UNION +MATCH (n:Company) RETURN n.name AS name + +// UNION ALL keeps duplicates +MATCH (n:Person) RETURN n.name AS name +UNION ALL +MATCH (n:Company) RETURN n.name AS name +``` + +### Control Flow + +#### FOREACH + +Iterate over list, execute updates. + +```cypher +// Set property on path nodes +MATCH path = (a)-[*]->(b) +FOREACH (n IN nodes(path) | SET n.visited = true) + +// Conditional operation (common pattern) +OPTIONAL MATCH (target:Node {id: $id}) +FOREACH (_ IN CASE WHEN target IS NOT NULL THEN [1] ELSE [] END | + CREATE (source)-[:LINKS_TO]->(target) +) +``` + +#### CASE + +Conditional expressions. + +```cypher +// Simple CASE +RETURN CASE n.status + WHEN 'active' THEN 'A' + WHEN 'pending' THEN 'P' + ELSE 'X' +END AS code + +// Generic CASE +RETURN CASE + WHEN n.age < 18 THEN 'minor' + WHEN n.age < 65 THEN 'adult' + ELSE 'senior' +END AS category +``` + +## Operators + +### Comparison + +| Operator | Description | +|----------|-------------| +| `=` | Equal | +| `<>` | Not equal | +| `<` | Less than | +| `>` | Greater than | +| `<=` | Less than or equal | +| `>=` | Greater than or equal | +| `IS NULL` | Is null | +| `IS NOT NULL` | Is not null | + +### Boolean + +| Operator | Description | +|----------|-------------| +| `AND` | Logical AND | +| `OR` | Logical OR | +| `NOT` | Logical NOT | +| `XOR` | Exclusive OR | + +### String + +| Operator | Description | +|----------|-------------| +| `STARTS WITH` | Prefix match | +| `ENDS WITH` | Suffix match | +| `CONTAINS` | Substring match | +| `=~` | Regex match | + +### List + +| Operator | Description | +|----------|-------------| +| `IN` | List membership | +| `+` | List concatenation | + +### Mathematical + +| Operator | Description | +|----------|-------------| +| `+` | Addition | +| `-` | Subtraction | +| `*` | Multiplication | +| `/` | Division | +| `%` | Modulo | +| `^` | Exponentiation | + +## Functions + +### Aggregation + +```cypher +count(*) // Count rows +count(n) // Count non-null +count(DISTINCT n) // Count unique +sum(n.value) // Sum +avg(n.value) // Average +min(n.value) // Minimum +max(n.value) // Maximum +collect(n) // Collect to list +collect(DISTINCT n) // Collect unique +stDev(n.value) // Standard deviation +percentileCont(n.value, 0.5) // Median +``` + +### Scalar + +```cypher +// Type functions +id(n) // Internal node ID (deprecated, use elementId) +elementId(n) // Element ID string +labels(n) // Node labels +type(r) // Relationship type +properties(n) // Property map + +// Math +abs(x) +ceil(x) +floor(x) +round(x) +sign(x) +sqrt(x) +rand() // Random 0-1 + +// String +size(str) // String length +toLower(str) +toUpper(str) +trim(str) +ltrim(str) +rtrim(str) +replace(str, from, to) +substring(str, start, len) +left(str, len) +right(str, len) +split(str, delimiter) +reverse(str) +toString(val) + +// Null handling +coalesce(val1, val2, ...) // First non-null +nullIf(val1, val2) // NULL if equal + +// Type conversion +toInteger(val) +toFloat(val) +toBoolean(val) +toString(val) +``` + +### List Functions + +```cypher +size(list) // List length +head(list) // First element +tail(list) // All but first +last(list) // Last element +range(start, end) // Create range [start..end] +range(start, end, step) +reverse(list) +keys(map) // Map keys as list +values(map) // Map values as list + +// List predicates +any(x IN list WHERE predicate) +all(x IN list WHERE predicate) +none(x IN list WHERE predicate) +single(x IN list WHERE predicate) + +// List manipulation +[x IN list WHERE predicate] // Filter +[x IN list | expression] // Map +[x IN list WHERE pred | expr] // Filter and map +reduce(s = initial, x IN list | s + x) // Reduce +``` + +### Path Functions + +```cypher +nodes(path) // Nodes in path +relationships(path) // Relationships in path +length(path) // Number of relationships +shortestPath((a)-[*]-(b)) +allShortestPaths((a)-[*]-(b)) +``` + +### Temporal Functions + +```cypher +timestamp() // Current Unix timestamp (ms) +datetime() // Current datetime +date() // Current date +time() // Current time +duration({days: 1, hours: 12}) + +// Components +datetime().year +datetime().month +datetime().day +datetime().hour + +// Parsing +date('2024-01-15') +datetime('2024-01-15T10:30:00Z') +``` + +### Spatial Functions + +```cypher +point({x: 1, y: 2}) +point({latitude: 37.5, longitude: -122.4}) +distance(point1, point2) +``` + +## Comments + +```cypher +// Single line comment + +/* Multi-line + comment */ +``` + +## Transaction Control + +```cypher +// In procedures/transactions +:begin +:commit +:rollback +``` + +## Parameter Syntax + +```cypher +// Parameter reference +$paramName + +// In properties +{key: $value} + +// In WHERE +WHERE n.id = $id + +// In expressions +RETURN $multiplier * n.value +``` diff --git a/.claude/skills/distributed-systems/SKILL.md b/.claude/skills/distributed-systems/SKILL.md new file mode 100644 index 00000000..c5af8b33 --- /dev/null +++ b/.claude/skills/distributed-systems/SKILL.md @@ -0,0 +1,1115 @@ +--- +name: distributed-systems +description: This skill should be used when designing or implementing distributed systems, understanding consensus protocols (Paxos, Raft, PBFT, Nakamoto, PnyxDB), analyzing CAP theorem trade-offs, implementing logical clocks (Lamport, Vector, ITC), or building fault-tolerant architectures. Provides comprehensive knowledge of consensus algorithms, Byzantine fault tolerance, adversarial oracle protocols, replication strategies, causality tracking, and distributed system design principles. +--- + +# Distributed Systems + +This skill provides deep knowledge of distributed systems design, consensus protocols, fault tolerance, and the fundamental trade-offs in building reliable distributed architectures. + +## When to Use This Skill + +- Designing distributed databases or storage systems +- Implementing consensus protocols (Raft, Paxos, PBFT, Nakamoto, PnyxDB) +- Analyzing system trade-offs using CAP theorem +- Building fault-tolerant or Byzantine fault-tolerant systems +- Understanding replication and consistency models +- Implementing causality tracking with logical clocks +- Building blockchain consensus mechanisms +- Designing decentralized oracle systems +- Understanding adversarial attack vectors in distributed systems + +## CAP Theorem + +### The Fundamental Trade-off + +The CAP theorem, introduced by Eric Brewer in 2000, states that a distributed data store cannot simultaneously provide more than two of: + +1. **Consistency (C)**: Every read receives the most recent write or an error +2. **Availability (A)**: Every request receives a non-error response (without guarantee of most recent data) +3. **Partition Tolerance (P)**: System continues operating despite network partitions + +### Why P is Non-Negotiable + +In any distributed system over a network: +- Network partitions **will** occur (cable cuts, router failures, congestion) +- A system that isn't partition-tolerant isn't truly distributed +- The real choice is between **CP** and **AP** during partitions + +### System Classifications + +#### CP Systems (Consistency + Partition Tolerance) + +**Behavior during partition**: Refuses some requests to maintain consistency. + +**Examples**: +- MongoDB (with majority write concern) +- HBase +- Zookeeper +- etcd + +**Use when**: +- Correctness is paramount (financial systems) +- Stale reads are unacceptable +- Brief unavailability is tolerable + +#### AP Systems (Availability + Partition Tolerance) + +**Behavior during partition**: Continues serving requests, may return stale data. + +**Examples**: +- Cassandra +- DynamoDB +- CouchDB +- Riak + +**Use when**: +- High availability is critical +- Eventual consistency is acceptable +- Shopping carts, social media feeds + +#### CA Systems + +**Theoretical only**: Cannot exist in distributed systems because partitions are inevitable. + +Single-node databases are technically CA but aren't distributed. + +### PACELC Extension + +PACELC extends CAP to address normal operation: + +> If there is a **P**artition, choose between **A**vailability and **C**onsistency. +> **E**lse (normal operation), choose between **L**atency and **C**onsistency. + +| System | P: A or C | E: L or C | +|--------|-----------|-----------| +| DynamoDB | A | L | +| Cassandra | A | L | +| MongoDB | C | C | +| PNUTS | C | L | + +## Consistency Models + +### Strong Consistency + +Every read returns the most recent write. Achieved through: +- Single leader with synchronous replication +- Consensus protocols (Paxos, Raft) + +**Trade-off**: Higher latency, lower availability during failures. + +### Eventual Consistency + +If no new updates, all replicas eventually converge to the same state. + +**Variants**: +- **Causal consistency**: Preserves causally related operations order +- **Read-your-writes**: Clients see their own writes +- **Monotonic reads**: Never see older data after seeing newer +- **Session consistency**: Consistency within a session + +### Linearizability + +Operations appear instantaneous at some point between invocation and response. + +**Provides**: +- Single-object operations appear atomic +- Real-time ordering guarantees +- Foundation for distributed locks, leader election + +### Serializability + +Transactions appear to execute in some serial order. + +**Note**: Linearizability ≠ Serializability +- Linearizability: Single-operation recency guarantee +- Serializability: Multi-operation isolation guarantee + +## Consensus Protocols + +### The Consensus Problem + +Getting distributed nodes to agree on a single value despite failures. + +**Requirements**: +1. **Agreement**: All correct nodes decide on the same value +2. **Validity**: Decided value was proposed by some node +3. **Termination**: All correct nodes eventually decide + +### Paxos + +Developed by Leslie Lamport (1989/1998), foundational consensus algorithm. + +#### Roles + +- **Proposers**: Propose values +- **Acceptors**: Vote on proposals +- **Learners**: Learn decided values + +#### Basic Protocol (Single-Decree) + +**Phase 1a: Prepare** +``` +Proposer → Acceptors: PREPARE(n) + - n is unique proposal number +``` + +**Phase 1b: Promise** +``` +Acceptor → Proposer: PROMISE(n, accepted_proposal) + - If n > highest_seen: promise to ignore lower proposals + - Return previously accepted proposal if any +``` + +**Phase 2a: Accept** +``` +Proposer → Acceptors: ACCEPT(n, v) + - v = value from highest accepted proposal, or proposer's own value +``` + +**Phase 2b: Accepted** +``` +Acceptor → Learners: ACCEPTED(n, v) + - If n >= highest_promised: accept the proposal +``` + +**Decision**: Value is decided when majority of acceptors accept it. + +#### Multi-Paxos + +Optimization for sequences of values: +- Elect stable leader +- Skip Phase 1 for subsequent proposals +- Significantly reduces message complexity + +#### Strengths and Weaknesses + +**Strengths**: +- Proven correct +- Tolerates f failures with 2f+1 nodes +- Foundation for many systems + +**Weaknesses**: +- Complex to implement correctly +- No specified leader election +- Performance requires Multi-Paxos optimizations + +### Raft + +Designed by Diego Ongaro and John Ousterhout (2013) for understandability. + +#### Key Design Principles + +1. **Decomposition**: Separates leader election, log replication, safety +2. **State reduction**: Minimizes states to consider +3. **Strong leader**: All writes through leader + +#### Server States + +- **Leader**: Handles all client requests, replicates log +- **Follower**: Passive, responds to leader and candidates +- **Candidate**: Trying to become leader + +#### Leader Election + +``` +1. Follower times out (no heartbeat from leader) +2. Becomes Candidate, increments term, votes for self +3. Requests votes from other servers +4. Wins with majority votes → becomes Leader +5. Loses (another leader) → becomes Follower +6. Timeout → starts new election +``` + +**Safety**: Only candidates with up-to-date logs can win. + +#### Log Replication + +``` +1. Client sends command to Leader +2. Leader appends to local log +3. Leader sends AppendEntries to Followers +4. On majority acknowledgment: entry is committed +5. Leader applies to state machine, responds to client +6. Followers apply committed entries +``` + +#### Log Matching Property + +If two logs contain entry with same index and term: +- Entries are identical +- All preceding entries are identical + +#### Term + +Logical clock that increases with each election: +- Detects stale leaders +- Resolves conflicts +- Included in all messages + +#### Comparison with Paxos + +| Aspect | Paxos | Raft | +|--------|-------|------| +| Understandability | Complex | Designed for clarity | +| Leader | Optional (Multi-Paxos) | Required | +| Log gaps | Allowed | Not allowed | +| Membership changes | Complex | Joint consensus | +| Implementations | Many variants | Consistent | + +### PBFT (Practical Byzantine Fault Tolerance) + +Developed by Castro and Liskov (1999) for Byzantine faults. + +#### Byzantine Faults + +Nodes can behave arbitrarily: +- Crash +- Send incorrect messages +- Collude maliciously +- Act inconsistently to different nodes + +#### Fault Tolerance + +Tolerates f Byzantine faults with **3f+1** nodes. + +**Why 3f+1?** +- Need 2f+1 honest responses +- f Byzantine nodes might lie +- Need f more to distinguish honest majority + +#### Protocol Phases + +**Normal Operation** (leader is honest): + +``` +1. REQUEST: Client → Primary (leader) +2. PRE-PREPARE: Primary → All replicas + - Primary assigns sequence number +3. PREPARE: Each replica → All replicas + - Validates pre-prepare +4. COMMIT: Each replica → All replicas + - After receiving 2f+1 prepares +5. REPLY: Each replica → Client + - After receiving 2f+1 commits +``` + +**Client waits for f+1 matching replies**. + +#### View Change + +When primary appears faulty: +1. Replicas timeout waiting for primary +2. Broadcast VIEW-CHANGE with prepared certificates +3. New primary collects 2f+1 view-changes +4. Broadcasts NEW-VIEW with proof +5. System resumes with new primary + +#### Message Complexity + +- **Normal case**: O(n²) messages per request +- **View change**: O(n³) messages + +**Scalability challenge**: Quadratic messaging limits cluster size. + +#### Optimizations + +- **Speculative execution**: Execute before commit +- **Batching**: Group multiple requests +- **Signatures**: Use MACs instead of digital signatures +- **Threshold signatures**: Reduce signature overhead + +### Modern BFT Variants + +#### HotStuff (2019) + +- Linear message complexity O(n) +- Used in LibraBFT (Diem), other blockchains +- Three-phase protocol with threshold signatures + +#### Tendermint + +- Blockchain-focused BFT +- Integrated with Cosmos SDK +- Immediate finality + +#### QBFT (Quorum BFT) + +- Enterprise-focused (ConsenSys/JPMorgan) +- Enhanced IBFT for Ethereum-based systems + +### Nakamoto Consensus + +The consensus mechanism powering Bitcoin, introduced by Satoshi Nakamoto (2008). + +#### Core Innovation + +Combines three elements: +1. **Proof-of-Work (PoW)**: Cryptographic puzzle for block creation +2. **Longest Chain Rule**: Fork resolution by accumulated work +3. **Probabilistic Finality**: Security increases with confirmations + +#### How It Works + +``` +1. Transactions broadcast to network +2. Miners collect transactions into blocks +3. Miners race to solve PoW puzzle: + - Find nonce such that Hash(block_header) < target + - Difficulty adjusts to maintain ~10 min block time +4. First miner to solve broadcasts block +5. Other nodes verify and append to longest chain +6. Miner receives block reward + transaction fees +``` + +#### Longest Chain Rule + +When forks occur: +``` +Chain A: [genesis] → [1] → [2] → [3] +Chain B: [genesis] → [1] → [2'] → [3'] → [4'] + +Nodes follow Chain B (more accumulated work) +Chain A blocks become "orphaned" +``` + +**Note**: Actually "most accumulated work" not "most blocks"—a chain with fewer but harder blocks wins. + +#### Security Model + +**Honest Majority Assumption**: Protocol secure if honest mining power > 50%. + +Formal analysis (Ren 2019): +``` +Safe if: g²α > β + +Where: + α = honest mining rate + β = adversarial mining rate + g = growth rate accounting for network delay + Δ = maximum network delay +``` + +**Implications**: +- Larger block interval → more security margin +- Higher network delay → need more honest majority +- 10-minute block time provides safety margin for global network + +#### Probabilistic Finality + +No instant finality—deeper blocks are exponentially harder to reverse: + +| Confirmations | Attack Probability (30% attacker) | +|---------------|-----------------------------------| +| 1 | ~50% | +| 3 | ~12% | +| 6 | ~0.2% | +| 12 | ~0.003% | + +**Convention**: 6 confirmations (~1 hour) considered "final" for Bitcoin. + +#### Attacks + +**51% Attack**: Attacker with majority hashrate can: +- Double-spend transactions +- Prevent confirmations +- NOT: steal funds, change consensus rules, create invalid transactions + +**Selfish Mining**: Strategic block withholding to waste honest miners' work. +- Profitable with < 50% hashrate under certain conditions +- Mitigated by network propagation improvements + +**Long-Range Attacks**: Not applicable to PoW (unlike PoS). + +#### Trade-offs vs Traditional BFT + +| Aspect | Nakamoto | Classical BFT | +|--------|----------|---------------| +| Finality | Probabilistic | Immediate | +| Throughput | Low (~7 TPS) | Higher | +| Participants | Permissionless | Permissioned | +| Energy | High (PoW) | Low | +| Fault tolerance | 50% hashrate | 33% nodes | +| Scalability | Global | Limited nodes | + +### PnyxDB: Leaderless Democratic BFT + +Developed by Bonniot, Neumann, and Taïani (2019) for consortia applications. + +#### Key Innovation: Conditional Endorsements + +Unlike leader-based BFT, PnyxDB uses **leaderless quorums** with conditional endorsements: +- Endorsements track conflicts between transactions +- If transactions commute (no conflicting operations), quorums built independently +- Non-commuting transactions handled via Byzantine Veto Procedure (BVP) + +#### Transaction Lifecycle + +``` +1. Client broadcasts transaction to endorsers +2. Endorsers evaluate against application-defined policies +3. If no conflicts: endorser sends acknowledgment +4. If conflicts detected: conditional endorsement specifying + which transactions must NOT be committed for this to be valid +5. Transaction commits when quorum of valid endorsements collected +6. BVP resolves conflicting transactions +``` + +#### Byzantine Veto Procedure (BVP) + +Ensures termination with conflicting transactions: +- Transactions have deadlines +- Conflicting endorsements trigger resolution loop +- Protocol guarantees exit when deadline passes +- At most f Byzantine nodes tolerated with n endorsers + +#### Application-Level Voting + +Unique feature: nodes can endorse or reject transactions based on **application-defined policies** without compromising consistency. + +Use cases: +- Consortium governance decisions +- Policy-based access control +- Democratic decision making + +#### Performance + +Compared to BFT-SMaRt and Tendermint: +- **11x faster** commit latencies +- **< 5 seconds** in worldwide geo-distributed deployment +- Tested with **180 nodes** + +#### Implementation + +- Written in Go (requires Go 1.11+) +- Uses gossip broadcast for message propagation +- Web-of-trust node authentication +- Scales to hundreds/thousands of nodes + +## Replication Strategies + +### Single-Leader Replication + +``` +Clients → Leader → Followers +``` + +**Pros**: Simple, strong consistency possible +**Cons**: Leader bottleneck, failover complexity + +#### Synchronous vs Asynchronous + +| Type | Durability | Latency | Availability | +|------|------------|---------|--------------| +| Synchronous | Guaranteed | High | Lower | +| Asynchronous | At-risk | Low | Higher | +| Semi-synchronous | Balanced | Medium | Medium | + +### Multi-Leader Replication + +Multiple nodes accept writes, replicate to each other. + +**Use cases**: +- Multi-datacenter deployment +- Clients with offline operation + +**Challenges**: +- Write conflicts +- Conflict resolution complexity + +#### Conflict Resolution + +- **Last-write-wins (LWW)**: Timestamp-based, may lose data +- **Application-specific**: Custom merge logic +- **CRDTs**: Mathematically guaranteed convergence + +### Leaderless Replication + +Any node can accept reads and writes. + +**Examples**: Dynamo, Cassandra, Riak + +#### Quorum Reads/Writes + +``` +n = total replicas +w = write quorum (nodes that must acknowledge write) +r = read quorum (nodes that must respond to read) + +For strong consistency: w + r > n +``` + +**Common configurations**: +- n=3, w=2, r=2: Tolerates 1 failure +- n=5, w=3, r=3: Tolerates 2 failures + +#### Sloppy Quorums and Hinted Handoff + +During partitions: +- Write to available nodes (even if not home replicas) +- "Hints" stored for unavailable nodes +- Hints replayed when nodes recover + +## Failure Modes + +### Crash Failures + +Node stops responding. Simplest failure model. + +**Detection**: Heartbeats, timeouts +**Tolerance**: 2f+1 nodes for f failures (Paxos, Raft) + +### Byzantine Failures + +Arbitrary behavior including malicious. + +**Detection**: Difficult without redundancy +**Tolerance**: 3f+1 nodes for f failures (PBFT) + +### Network Partitions + +Nodes can't communicate with some other nodes. + +**Impact**: Forces CP vs AP choice +**Recovery**: Reconciliation after partition heals + +### Split Brain + +Multiple nodes believe they are leader. + +**Prevention**: +- Fencing (STONITH: Shoot The Other Node In The Head) +- Quorum-based leader election +- Lease-based leadership + +## Design Patterns + +### State Machine Replication + +Replicate deterministic state machine across nodes: +1. All replicas start in same state +2. Apply same commands in same order +3. All reach same final state + +**Requires**: Total order broadcast (consensus) + +### Chain Replication + +``` +Head → Node2 → Node3 → ... → Tail +``` + +- Writes enter at head, propagate down chain +- Reads served by tail (strongly consistent) +- Simple, high throughput + +### Primary-Backup + +Primary handles all operations, synchronously replicates to backups. + +**Failover**: Backup promoted to primary on failure. + +### Quorum Systems + +Intersecting sets ensure consistency: +- Any read quorum intersects any write quorum +- Guarantees reads see latest write + +## Balancing Trade-offs + +### Identifying Critical Requirements + +1. **Correctness requirements** + - Is data loss acceptable? + - Can operations be reordered? + - Are conflicts resolvable? + +2. **Availability requirements** + - What's acceptable downtime? + - Geographic distribution needs? + - Partition recovery strategy? + +3. **Performance requirements** + - Latency targets? + - Throughput needs? + - Consistency cost tolerance? + +### Vulnerability Mitigation by Protocol + +#### Paxos/Raft (Crash Fault Tolerant) + +**Vulnerabilities**: +- Leader failure causes brief unavailability +- Split-brain without proper fencing +- Slow follower impacts commit latency (sync replication) + +**Mitigations**: +- Fast leader election (pre-voting) +- Quorum-based fencing +- Flexible quorum configurations +- Learner nodes for read scaling + +#### PBFT (Byzantine Fault Tolerant) + +**Vulnerabilities**: +- O(n²) messages limit scalability +- View change is expensive +- Requires 3f+1 nodes (more infrastructure) + +**Mitigations**: +- Batching and pipelining +- Optimistic execution (HotStuff) +- Threshold signatures +- Hierarchical consensus for scaling + +### Choosing the Right Protocol + +| Scenario | Recommended | Rationale | +|----------|-------------|-----------| +| Internal infrastructure | Raft | Simple, well-understood | +| High consistency needs | Raft/Paxos | Proven correctness | +| Public/untrusted network | PBFT variant | Byzantine tolerance | +| Blockchain | HotStuff/Tendermint | Linear complexity BFT | +| Eventually consistent | Dynamo-style | High availability | +| Global distribution | Multi-leader + CRDTs | Partition tolerance | + +## Implementation Considerations + +### Timeouts + +- **Heartbeat interval**: 100-300ms typical +- **Election timeout**: 10x heartbeat (avoid split votes) +- **Request timeout**: Application-dependent + +### Persistence + +What must be persisted before acknowledgment: +- **Raft**: Current term, voted-for, log entries +- **PBFT**: View number, prepared/committed certificates + +### Membership Changes + +Dynamic cluster membership: +- **Raft**: Joint consensus (old + new config) +- **Paxos**: α-reconfiguration +- **PBFT**: View change with new configuration + +### Testing + +- **Jepsen**: Distributed systems testing framework +- **Chaos engineering**: Intentional failure injection +- **Formal verification**: TLA+, Coq proofs + +## Adversarial Oracle Protocols + +Oracles bridge on-chain smart contracts with off-chain data, but introduce trust assumptions into trustless systems. + +### The Oracle Problem + +**Definition**: The security, authenticity, and trust conflict between third-party oracles and the trustless execution of smart contracts. + +**Core Challenge**: Blockchains cannot verify correctness of external data. Oracles become: +- Single points of failure +- Targets for manipulation +- Trust assumptions in "trustless" systems + +### Attack Vectors + +#### Price Oracle Manipulation + +**Flash Loan Attacks**: +``` +1. Borrow large amount via flash loan (no collateral) +2. Manipulate price on DEX (large trade) +3. Oracle reads manipulated price +4. Smart contract executes with wrong price +5. Profit from arbitrage/liquidation +6. Repay flash loan in same transaction +``` + +**Notable Example**: Harvest Finance ($30M+ loss, 2020) + +#### Data Source Attacks + +- **Compromised API**: Single data source manipulation +- **Front-running**: Oracle updates exploited before on-chain +- **Liveness attacks**: Preventing oracle updates +- **Bribery**: Incentivizing oracle operators to lie + +#### Economic Attacks + +**Cost of Corruption Analysis**: +``` +If oracle controls value V: + - Attack profit: V + - Attack cost: oracle stake + reputation + - Rational to attack if: profit > cost +``` + +**Implication**: Oracles must have stake > value they secure. + +### Decentralized Oracle Networks (DONs) + +#### Chainlink Model + +**Multi-layer Security**: +``` +1. Multiple independent data sources +2. Multiple independent node operators +3. Aggregation (median, weighted average) +4. Reputation system +5. Cryptoeconomic incentives (staking) +``` + +**Data Aggregation**: +``` +Nodes: [Oracle₁: $100, Oracle₂: $101, Oracle₃: $150, Oracle₄: $100] +Median: $100.50 +Outlier (Oracle₃) has minimal impact +``` + +#### Reputation and Staking + +``` +Node reputation based on: + - Historical accuracy + - Response time + - Uptime + - Stake amount + +Job assignment weighted by reputation +Slashing for misbehavior +``` + +### Oracle Design Patterns + +#### Time-Weighted Average Price (TWAP) + +Resist single-block manipulation: +``` +TWAP = Σ(price_i × duration_i) / total_duration + +Example over 1 hour: + - 30 min at $100: 30 × 100 = 3000 + - 20 min at $101: 20 × 101 = 2020 + - 10 min at $150 (manipulation): 10 × 150 = 1500 + TWAP = 6520 / 60 = $108.67 (vs $150 spot) +``` + +#### Commit-Reveal Schemes + +Prevent front-running oracle updates: +``` +Phase 1 (Commit): + - Oracle commits: hash(price || salt) + - Cannot be read by others + +Phase 2 (Reveal): + - Oracle reveals: price, salt + - Contract verifies hash matches + - All oracles reveal simultaneously +``` + +#### Schelling Points + +Game-theoretic oracle coordination: +``` +1. Multiple oracles submit answers +2. Consensus answer determined +3. Oracles matching consensus rewarded +4. Outliers penalized + +Assumption: Honest answer is "obvious" Schelling point +``` + +### Trusted Execution Environments (TEEs) + +Hardware-based oracle security: +``` +TEE (Intel SGX, ARM TrustZone): + - Isolated execution environment + - Code attestation + - Protected memory + - External data fetching inside enclave +``` + +**Benefits**: +- Verifiable computation +- Protected from host machine +- Cryptographic proofs of execution + +**Limitations**: +- Hardware trust assumption +- Side-channel attacks possible +- Intel SGX vulnerabilities discovered + +### Oracle Types by Data Source + +| Type | Source | Trust Model | Use Case | +|------|--------|-------------|----------| +| Price feeds | Exchanges | Multiple sources | DeFi | +| Randomness | VRF/DRAND | Cryptographic | Gaming, NFTs | +| Event outcomes | Manual report | Reputation | Prediction markets | +| Cross-chain | Other blockchains | Bridge security | Interoperability | +| Computation | Off-chain compute | Verifiable | Complex logic | + +### Defense Mechanisms + +1. **Diversification**: Multiple independent oracles +2. **Economic security**: Stake > protected value +3. **Time delays**: Allow dispute periods +4. **Circuit breakers**: Pause on anomalous data +5. **TWAP**: Resist flash manipulation +6. **Commit-reveal**: Prevent front-running +7. **Reputation**: Long-term incentives + +### Hybrid Approaches + +**Optimistic Oracles**: +``` +1. Oracle posts answer + bond +2. Dispute window (e.g., 2 hours) +3. If disputed: escalate to arbitration +4. If not disputed: answer accepted +5. Incorrect oracle loses bond +``` + +**Examples**: UMA Protocol, Optimistic Oracle + +## Causality and Logical Clocks + +Physical clocks cannot reliably order events in distributed systems due to clock drift and synchronization issues. Logical clocks provide ordering based on causality. + +### The Happened-Before Relation + +Defined by Leslie Lamport (1978): + +Event a **happened-before** event b (a → b) if: +1. a and b are in the same process, and a comes before b +2. a is a send event and b is the corresponding receive +3. There exists c such that a → c and c → b (transitivity) + +If neither a → b nor b → a, events are **concurrent** (a || b). + +### Lamport Clocks + +Simple scalar timestamps providing partial ordering. + +**Rules**: +``` +1. Each process maintains counter C +2. Before each event: C = C + 1 +3. Send message m with timestamp C +4. On receive: C = max(C, message_timestamp) + 1 +``` + +**Properties**: +- If a → b, then C(a) < C(b) +- **Limitation**: C(a) < C(b) does NOT imply a → b +- Cannot detect concurrent events + +**Use cases**: +- Total ordering with tie-breaker (process ID) +- Distributed snapshots +- Simple event ordering + +### Vector Clocks + +Array of counters, one per process. Captures full causality. + +**Structure** (for n processes): +``` +VC[1..n] where VC[i] is process i's logical time +``` + +**Rules** (at process i): +``` +1. Before each event: VC[i] = VC[i] + 1 +2. Send message with full vector VC +3. On receive from j: + for k in 1..n: + VC[k] = max(VC[k], received_VC[k]) + VC[i] = VC[i] + 1 +``` + +**Comparison** (for vectors V1 and V2): +``` +V1 = V2 iff ∀i: V1[i] = V2[i] +V1 ≤ V2 iff ∀i: V1[i] ≤ V2[i] +V1 < V2 iff V1 ≤ V2 and V1 ≠ V2 +V1 || V2 iff NOT(V1 ≤ V2) and NOT(V2 ≤ V1) # concurrent +``` + +**Properties**: +- a → b iff VC(a) < VC(b) +- a || b iff VC(a) || VC(b) +- **Full causality detection** + +**Trade-off**: O(n) space per event, where n = number of processes. + +### Interval Tree Clocks (ITC) + +Developed by Almeida, Baquero, and Fonte (2008) for dynamic systems. + +**Problem with Vector Clocks**: +- Static: size fixed to max number of processes +- ID retirement requires global coordination +- Unsuitable for high-churn systems (P2P) + +**ITC Solution**: +- Binary tree structure for ID space +- Dynamic ID allocation and deallocation +- Localized fork/join operations + +**Core Operations**: + +``` +fork(id): Split ID into two children + - Parent retains left half + - New process gets right half + +join(id1, id2): Merge two IDs + - Combine ID trees + - Localized operation, no global coordination + +event(id, stamp): Increment logical clock +peek(id, stamp): Read without increment +``` + +**ID Space Representation**: +``` + 1 # Full ID space + / \ + 0 1 # After one fork + / \ + 0 1 # After another fork (left child) +``` + +**Stamp (Clock) Representation**: +- Tree structure mirrors ID space +- Each node has base value + optional children +- Efficient representation of sparse vectors + +**Example**: +``` +Initial: id=(1), stamp=0 +Fork: id1=(1,0), stamp1=0 + id2=(0,1), stamp2=0 +Event at id1: stamp1=(0,(1,0)) +Join id1+id2: id=(1), stamp=max of both +``` + +**Advantages over Vector Clocks**: +- Constant-size representation possible +- Dynamic membership without global state +- Efficient ID garbage collection +- Causality preserved across reconfigurations + +**Use cases**: +- Peer-to-peer systems +- Mobile/ad-hoc networks +- Systems with frequent node join/leave + +### Version Vectors + +Specialization of vector clocks for tracking data versions. + +**Difference from Vector Clocks**: +- Vector clocks: track all events +- Version vectors: track data updates only + +**Usage in Dynamo-style systems**: +``` +Client reads with version vector V1 +Client writes with version vector V2 +Server compares: + - If V1 < current: stale read, conflict possible + - If V1 = current: safe update + - If V1 || current: concurrent writes, need resolution +``` + +### Hybrid Logical Clocks (HLC) + +Combines physical and logical time. + +**Structure**: +``` +HLC = (physical_time, logical_counter) +``` + +**Rules**: +``` +1. On local/send event: + pt = physical_clock() + if pt > l: + l = pt + c = 0 + else: + c = c + 1 + return (l, c) + +2. On receive with timestamp (l', c'): + pt = physical_clock() + if pt > l and pt > l': + l = pt + c = 0 + elif l' > l: + l = l' + c = c' + 1 + elif l > l': + c = c + 1 + else: # l = l' + c = max(c, c') + 1 + return (l, c) +``` + +**Properties**: +- Bounded drift from physical time +- Captures causality like Lamport clocks +- Timestamps comparable to wall-clock time +- Used in CockroachDB, Google Spanner + +### Comparison of Logical Clocks + +| Clock Type | Space | Causality | Concurrency | Dynamic | +|------------|-------|-----------|-------------|---------| +| Lamport | O(1) | Partial | No | Yes | +| Vector | O(n) | Full | Yes | No | +| ITC | O(log n)* | Full | Yes | Yes | +| HLC | O(1) | Partial | No | Yes | + +*ITC space varies based on tree structure + +### Practical Applications + +**Conflict Detection** (Vector Clocks): +``` +if V1 < V2: + # v1 is ancestor of v2, no conflict +elif V1 > V2: + # v2 is ancestor of v1, no conflict +else: # V1 || V2 + # Concurrent updates, need conflict resolution +``` + +**Causal Broadcast**: +``` +Deliver message m with VC only when: +1. VC[sender] = local_VC[sender] + 1 (next expected from sender) +2. ∀j ≠ sender: VC[j] ≤ local_VC[j] (all causal deps satisfied) +``` + +**Snapshot Algorithms**: +``` +Consistent cut: set of events S where + if e ∈ S and f → e, then f ∈ S +Vector clocks make this efficiently verifiable +``` + +## References + +For detailed protocol specifications and proofs, see: +- `references/consensus-protocols.md` - Detailed protocol descriptions +- `references/consistency-models.md` - Formal consistency definitions +- `references/failure-scenarios.md` - Failure mode analysis +- `references/logical-clocks.md` - Clock algorithms and implementations diff --git a/.claude/skills/distributed-systems/references/consensus-protocols.md b/.claude/skills/distributed-systems/references/consensus-protocols.md new file mode 100644 index 00000000..a3bfb071 --- /dev/null +++ b/.claude/skills/distributed-systems/references/consensus-protocols.md @@ -0,0 +1,610 @@ +# Consensus Protocols - Detailed Reference + +Complete specifications and implementation details for major consensus protocols. + +## Paxos Complete Specification + +### Proposal Numbers + +Proposal numbers must be: +- **Unique**: No two proposers use the same number +- **Totally ordered**: Any two can be compared + +**Implementation**: `(round_number, proposer_id)` where proposer_id breaks ties. + +### Single-Decree Paxos State + +**Proposer state**: +``` +proposal_number: int +value: any +``` + +**Acceptor state (persistent)**: +``` +highest_promised: int # Highest proposal number promised +accepted_proposal: int # Number of accepted proposal (0 if none) +accepted_value: any # Value of accepted proposal (null if none) +``` + +### Message Format + +**Prepare** (Phase 1a): +``` +{ + type: "PREPARE", + proposal_number: n +} +``` + +**Promise** (Phase 1b): +``` +{ + type: "PROMISE", + proposal_number: n, + accepted_proposal: m, # null if nothing accepted + accepted_value: v # null if nothing accepted +} +``` + +**Accept** (Phase 2a): +``` +{ + type: "ACCEPT", + proposal_number: n, + value: v +} +``` + +**Accepted** (Phase 2b): +``` +{ + type: "ACCEPTED", + proposal_number: n, + value: v +} +``` + +### Proposer Algorithm + +``` +function propose(value): + n = generate_proposal_number() + + # Phase 1: Prepare + promises = [] + for acceptor in acceptors: + send PREPARE(n) to acceptor + + wait until |promises| > |acceptors|/2 or timeout + + if timeout: + return FAILED + + # Choose value + highest = max(promises, key=p.accepted_proposal) + if highest.accepted_value is not null: + value = highest.accepted_value + + # Phase 2: Accept + accepts = [] + for acceptor in acceptors: + send ACCEPT(n, value) to acceptor + + wait until |accepts| > |acceptors|/2 or timeout + + if timeout: + return FAILED + + return SUCCESS(value) +``` + +### Acceptor Algorithm + +``` +on receive PREPARE(n): + if n > highest_promised: + highest_promised = n + persist(highest_promised) + reply PROMISE(n, accepted_proposal, accepted_value) + else: + # Optionally reply NACK(highest_promised) + ignore or reject + +on receive ACCEPT(n, v): + if n >= highest_promised: + highest_promised = n + accepted_proposal = n + accepted_value = v + persist(highest_promised, accepted_proposal, accepted_value) + reply ACCEPTED(n, v) + else: + ignore or reject +``` + +### Multi-Paxos Optimization + +**Stable leader**: +``` +# Leader election (using Paxos or other method) +leader = elect_leader() + +# Leader's Phase 1 for all future instances +leader sends PREPARE(n) for instance range [i, ∞) + +# For each command: +function propose_as_leader(value, instance): + # Skip Phase 1 if already leader + for acceptor in acceptors: + send ACCEPT(n, value, instance) to acceptor + wait for majority ACCEPTED + return SUCCESS +``` + +### Paxos Safety Proof Sketch + +**Invariant**: If a value v is chosen for instance i, no other value can be chosen. + +**Proof**: +1. Value chosen → accepted by majority with proposal n +2. Any higher proposal n' must contact majority +3. Majorities intersect → at least one acceptor has accepted v +4. New proposer adopts v (or higher already-accepted value) +5. By induction, all future proposals use v + +## Raft Complete Specification + +### State + +**All servers (persistent)**: +``` +currentTerm: int # Latest term seen +votedFor: ServerId # Candidate voted for in current term (null if none) +log[]: LogEntry # Log entries +``` + +**All servers (volatile)**: +``` +commitIndex: int # Highest log index known to be committed +lastApplied: int # Highest log index applied to state machine +``` + +**Leader (volatile, reinitialized after election)**: +``` +nextIndex[]: int # For each server, next log index to send +matchIndex[]: int # For each server, highest log index replicated +``` + +**LogEntry**: +``` +{ + term: int, + command: any +} +``` + +### RequestVote RPC + +**Request**: +``` +{ + term: int, # Candidate's term + candidateId: ServerId, # Candidate requesting vote + lastLogIndex: int, # Index of candidate's last log entry + lastLogTerm: int # Term of candidate's last log entry +} +``` + +**Response**: +``` +{ + term: int, # currentTerm, for candidate to update itself + voteGranted: bool # True if candidate received vote +} +``` + +**Receiver implementation**: +``` +on receive RequestVote(term, candidateId, lastLogIndex, lastLogTerm): + if term < currentTerm: + return {term: currentTerm, voteGranted: false} + + if term > currentTerm: + currentTerm = term + votedFor = null + convert to follower + + # Check if candidate's log is at least as up-to-date as ours + ourLastTerm = log[len(log)-1].term if log else 0 + ourLastIndex = len(log) - 1 + + logOK = (lastLogTerm > ourLastTerm) or + (lastLogTerm == ourLastTerm and lastLogIndex >= ourLastIndex) + + if (votedFor is null or votedFor == candidateId) and logOK: + votedFor = candidateId + persist(currentTerm, votedFor) + reset election timer + return {term: currentTerm, voteGranted: true} + + return {term: currentTerm, voteGranted: false} +``` + +### AppendEntries RPC + +**Request**: +``` +{ + term: int, # Leader's term + leaderId: ServerId, # For follower to redirect clients + prevLogIndex: int, # Index of log entry preceding new ones + prevLogTerm: int, # Term of prevLogIndex entry + entries[]: LogEntry, # Log entries to store (empty for heartbeat) + leaderCommit: int # Leader's commitIndex +} +``` + +**Response**: +``` +{ + term: int, # currentTerm, for leader to update itself + success: bool # True if follower had matching prevLog entry +} +``` + +**Receiver implementation**: +``` +on receive AppendEntries(term, leaderId, prevLogIndex, prevLogTerm, entries, leaderCommit): + if term < currentTerm: + return {term: currentTerm, success: false} + + reset election timer + + if term > currentTerm: + currentTerm = term + votedFor = null + + convert to follower + + # Check log consistency + if prevLogIndex >= len(log) or + (prevLogIndex >= 0 and log[prevLogIndex].term != prevLogTerm): + return {term: currentTerm, success: false} + + # Append new entries (handling conflicts) + for i, entry in enumerate(entries): + index = prevLogIndex + 1 + i + if index < len(log): + if log[index].term != entry.term: + # Delete conflicting entry and all following + log = log[:index] + log.append(entry) + else: + log.append(entry) + + persist(currentTerm, votedFor, log) + + # Update commit index + if leaderCommit > commitIndex: + commitIndex = min(leaderCommit, len(log) - 1) + + return {term: currentTerm, success: true} +``` + +### Leader Behavior + +``` +on becoming leader: + for each server: + nextIndex[server] = len(log) + matchIndex[server] = 0 + + start sending heartbeats + +on receiving client command: + append entry to local log + persist log + send AppendEntries to all followers + +on receiving AppendEntries response from server: + if response.success: + matchIndex[server] = prevLogIndex + len(entries) + nextIndex[server] = matchIndex[server] + 1 + + # Update commit index + for N from commitIndex+1 to len(log)-1: + if log[N].term == currentTerm and + |{s : matchIndex[s] >= N}| > |servers|/2: + commitIndex = N + else: + nextIndex[server] = max(1, nextIndex[server] - 1) + retry AppendEntries with lower prevLogIndex + +on commitIndex update: + while lastApplied < commitIndex: + lastApplied++ + apply log[lastApplied].command to state machine +``` + +### Election Timeout + +``` +on election timeout (follower or candidate): + currentTerm++ + convert to candidate + votedFor = self + persist(currentTerm, votedFor) + reset election timer + votes = 1 # Vote for self + + for each server except self: + send RequestVote(currentTerm, self, lastLogIndex, lastLogTerm) + + wait for responses or timeout: + if received votes > |servers|/2: + become leader + if received AppendEntries from valid leader: + become follower + if timeout: + start new election +``` + +## PBFT Complete Specification + +### Message Types + +**REQUEST**: +``` +{ + type: "REQUEST", + operation: o, # Operation to execute + timestamp: t, # Client timestamp (for reply matching) + client: c # Client identifier +} +``` + +**PRE-PREPARE**: +``` +{ + type: "PRE-PREPARE", + view: v, # Current view number + sequence: n, # Sequence number + digest: d, # Hash of request + request: m # The request message +} +signature(primary) +``` + +**PREPARE**: +``` +{ + type: "PREPARE", + view: v, + sequence: n, + digest: d, + replica: i # Sending replica +} +signature(replica_i) +``` + +**COMMIT**: +``` +{ + type: "COMMIT", + view: v, + sequence: n, + digest: d, + replica: i +} +signature(replica_i) +``` + +**REPLY**: +``` +{ + type: "REPLY", + view: v, + timestamp: t, + client: c, + replica: i, + result: r # Execution result +} +signature(replica_i) +``` + +### Replica State + +``` +view: int # Current view +sequence: int # Last assigned sequence number (primary) +log[]: {request, prepares, commits, state} # Log of requests +prepared_certificates: {} # Prepared certificates (2f+1 prepares) +committed_certificates: {} # Committed certificates (2f+1 commits) +h: int # Low water mark +H: int # High water mark (h + L) +``` + +### Normal Operation Protocol + +**Primary (replica p = v mod n)**: +``` +on receive REQUEST(m) from client: + if not primary for current view: + forward to primary + return + + n = assign_sequence_number() + d = hash(m) + + broadcast PRE-PREPARE(v, n, d, m) to all replicas + add to log +``` + +**All replicas**: +``` +on receive PRE-PREPARE(v, n, d, m) from primary: + if v != current_view: + ignore + if already accepted pre-prepare for (v, n) with different digest: + ignore + if not in_view_as_backup(v): + ignore + if not h < n <= H: + ignore # Outside sequence window + + # Valid pre-prepare + add to log + broadcast PREPARE(v, n, d, i) to all replicas + +on receive PREPARE(v, n, d, j) from replica j: + if v != current_view: + ignore + + add to log[n].prepares + + if |log[n].prepares| >= 2f and not already_prepared(v, n, d): + # Prepared certificate complete + mark as prepared + broadcast COMMIT(v, n, d, i) to all replicas + +on receive COMMIT(v, n, d, j) from replica j: + if v != current_view: + ignore + + add to log[n].commits + + if |log[n].commits| >= 2f + 1 and prepared(v, n, d): + # Committed certificate complete + if all entries < n are committed: + execute(m) + send REPLY(v, t, c, i, result) to client +``` + +### View Change Protocol + +**Timeout trigger**: +``` +on request timeout (no progress): + view_change_timeout++ + broadcast VIEW-CHANGE(v+1, n, C, P, i) + + where: + n = last stable checkpoint sequence number + C = checkpoint certificate (2f+1 checkpoint messages) + P = set of prepared certificates for messages after n +``` + +**VIEW-CHANGE**: +``` +{ + type: "VIEW-CHANGE", + view: v, # New view number + sequence: n, # Checkpoint sequence + checkpoints: C, # Checkpoint certificate + prepared: P, # Set of prepared certificates + replica: i +} +signature(replica_i) +``` + +**New primary (p' = v mod n)**: +``` +on receive 2f VIEW-CHANGE for view v: + V = set of valid view-change messages + + # Compute O: set of requests to re-propose + O = {} + for seq in max_checkpoint_seq(V) to max_seq(V): + if exists prepared certificate for seq in V: + O[seq] = request from certificate + else: + O[seq] = null-request # No-op + + broadcast NEW-VIEW(v, V, O) + + # Re-run protocol for requests in O + for seq, request in O: + if request != null: + send PRE-PREPARE(v, seq, hash(request), request) +``` + +**NEW-VIEW**: +``` +{ + type: "NEW-VIEW", + view: v, + view_changes: V, # 2f+1 view-change messages + pre_prepares: O # Set of pre-prepare messages +} +signature(primary) +``` + +### Checkpointing + +Periodic stable checkpoints to garbage collect logs: + +``` +every K requests: + state_hash = hash(state_machine_state) + broadcast CHECKPOINT(n, state_hash, i) + +on receive 2f+1 CHECKPOINT for (n, d): + if all digests match: + create stable checkpoint + h = n # Move low water mark + garbage_collect(entries < n) +``` + +## HotStuff Protocol + +Linear complexity BFT using threshold signatures. + +### Key Innovation + +- **Three-phase**: prepare → pre-commit → commit → decide +- **Pipelining**: Next proposal starts before current finishes +- **Threshold signatures**: O(n) total messages instead of O(n²) + +### Message Flow + +``` +Phase 1 (Prepare): + Leader: broadcast PREPARE(v, node) + Replicas: sign and send partial signature to leader + Leader: aggregate into prepare certificate QC + +Phase 2 (Pre-commit): + Leader: broadcast PRE-COMMIT(v, QC_prepare) + Replicas: sign and send partial signature + Leader: aggregate into pre-commit certificate + +Phase 3 (Commit): + Leader: broadcast COMMIT(v, QC_precommit) + Replicas: sign and send partial signature + Leader: aggregate into commit certificate + +Phase 4 (Decide): + Leader: broadcast DECIDE(v, QC_commit) + Replicas: execute and commit +``` + +### Pipelining + +``` +Block k: [prepare] [pre-commit] [commit] [decide] +Block k+1: [prepare] [pre-commit] [commit] [decide] +Block k+2: [prepare] [pre-commit] [commit] [decide] +``` + +Each phase of block k+1 piggybacks on messages for block k. + +## Protocol Comparison Matrix + +| Feature | Paxos | Raft | PBFT | HotStuff | +|---------|-------|------|------|----------| +| Fault model | Crash | Crash | Byzantine | Byzantine | +| Fault tolerance | f with 2f+1 | f with 2f+1 | f with 3f+1 | f with 3f+1 | +| Message complexity | O(n) | O(n) | O(n²) | O(n) | +| Leader required | No (helps) | Yes | Yes | Yes | +| Phases | 2 | 2 | 3 | 3 | +| View change | Complex | Simple | Complex | Simple | diff --git a/.claude/skills/distributed-systems/references/logical-clocks.md b/.claude/skills/distributed-systems/references/logical-clocks.md new file mode 100644 index 00000000..c8b09faf --- /dev/null +++ b/.claude/skills/distributed-systems/references/logical-clocks.md @@ -0,0 +1,610 @@ +# Logical Clocks - Implementation Reference + +Detailed implementations and algorithms for causality tracking. + +## Lamport Clock Implementation + +### Data Structure + +```go +type LamportClock struct { + counter uint64 + mu sync.Mutex +} + +func NewLamportClock() *LamportClock { + return &LamportClock{counter: 0} +} +``` + +### Operations + +```go +// Tick increments clock for local event +func (c *LamportClock) Tick() uint64 { + c.mu.Lock() + defer c.mu.Unlock() + c.counter++ + return c.counter +} + +// Send returns timestamp for outgoing message +func (c *LamportClock) Send() uint64 { + return c.Tick() +} + +// Receive updates clock based on incoming message timestamp +func (c *LamportClock) Receive(msgTime uint64) uint64 { + c.mu.Lock() + defer c.mu.Unlock() + + if msgTime > c.counter { + c.counter = msgTime + } + c.counter++ + return c.counter +} + +// Time returns current clock value without incrementing +func (c *LamportClock) Time() uint64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.counter +} +``` + +### Usage Example + +```go +// Process A +clockA := NewLamportClock() +e1 := clockA.Tick() // Event 1: time=1 +msgTime := clockA.Send() // Send: time=2 + +// Process B +clockB := NewLamportClock() +e2 := clockB.Tick() // Event 2: time=1 +e3 := clockB.Receive(msgTime) // Receive: time=3 (max(1,2)+1) +``` + +## Vector Clock Implementation + +### Data Structure + +```go +type VectorClock struct { + clocks map[string]uint64 // processID -> logical time + self string // this process's ID + mu sync.RWMutex +} + +func NewVectorClock(processID string, allProcesses []string) *VectorClock { + clocks := make(map[string]uint64) + for _, p := range allProcesses { + clocks[p] = 0 + } + return &VectorClock{ + clocks: clocks, + self: processID, + } +} +``` + +### Operations + +```go +// Tick increments own clock +func (vc *VectorClock) Tick() map[string]uint64 { + vc.mu.Lock() + defer vc.mu.Unlock() + + vc.clocks[vc.self]++ + return vc.copy() +} + +// Send returns copy of vector for message +func (vc *VectorClock) Send() map[string]uint64 { + return vc.Tick() +} + +// Receive merges incoming vector and increments +func (vc *VectorClock) Receive(incoming map[string]uint64) map[string]uint64 { + vc.mu.Lock() + defer vc.mu.Unlock() + + // Merge: take max of each component + for pid, time := range incoming { + if time > vc.clocks[pid] { + vc.clocks[pid] = time + } + } + + // Increment own clock + vc.clocks[vc.self]++ + return vc.copy() +} + +// copy returns a copy of the vector +func (vc *VectorClock) copy() map[string]uint64 { + result := make(map[string]uint64) + for k, v := range vc.clocks { + result[k] = v + } + return result +} +``` + +### Comparison Functions + +```go +// Compare returns ordering relationship between two vectors +type Ordering int + +const ( + Equal Ordering = iota // V1 == V2 + HappenedBefore // V1 < V2 + HappenedAfter // V1 > V2 + Concurrent // V1 || V2 +) + +func Compare(v1, v2 map[string]uint64) Ordering { + less := false + greater := false + + // Get all keys + allKeys := make(map[string]bool) + for k := range v1 { + allKeys[k] = true + } + for k := range v2 { + allKeys[k] = true + } + + for k := range allKeys { + t1 := v1[k] // 0 if not present + t2 := v2[k] + + if t1 < t2 { + less = true + } + if t1 > t2 { + greater = true + } + } + + if !less && !greater { + return Equal + } + if less && !greater { + return HappenedBefore + } + if greater && !less { + return HappenedAfter + } + return Concurrent +} + +// IsConcurrent checks if two events are concurrent +func IsConcurrent(v1, v2 map[string]uint64) bool { + return Compare(v1, v2) == Concurrent +} + +// HappenedBefore checks if v1 -> v2 (v1 causally precedes v2) +func HappenedBefore(v1, v2 map[string]uint64) bool { + return Compare(v1, v2) == HappenedBefore +} +``` + +## Interval Tree Clock Implementation + +### Data Structures + +```go +// ID represents the identity tree +type ID struct { + IsLeaf bool + Value int // 0 or 1 for leaves + Left *ID // nil for leaves + Right *ID +} + +// Stamp represents the event tree +type Stamp struct { + Base int + Left *Stamp // nil for leaf stamps + Right *Stamp +} + +// ITC combines ID and Stamp +type ITC struct { + ID *ID + Stamp *Stamp +} +``` + +### ID Operations + +```go +// NewSeedID creates initial full ID (1) +func NewSeedID() *ID { + return &ID{IsLeaf: true, Value: 1} +} + +// Fork splits an ID into two +func (id *ID) Fork() (*ID, *ID) { + if id.IsLeaf { + if id.Value == 0 { + // Cannot fork zero ID + return &ID{IsLeaf: true, Value: 0}, + &ID{IsLeaf: true, Value: 0} + } + // Split full ID into left and right halves + return &ID{ + IsLeaf: false, + Left: &ID{IsLeaf: true, Value: 1}, + Right: &ID{IsLeaf: true, Value: 0}, + }, + &ID{ + IsLeaf: false, + Left: &ID{IsLeaf: true, Value: 0}, + Right: &ID{IsLeaf: true, Value: 1}, + } + } + + // Fork from non-leaf: give half to each + if id.Left.IsLeaf && id.Left.Value == 0 { + // Left is zero, fork right + newRight1, newRight2 := id.Right.Fork() + return &ID{IsLeaf: false, Left: id.Left, Right: newRight1}, + &ID{IsLeaf: false, Left: &ID{IsLeaf: true, Value: 0}, Right: newRight2} + } + if id.Right.IsLeaf && id.Right.Value == 0 { + // Right is zero, fork left + newLeft1, newLeft2 := id.Left.Fork() + return &ID{IsLeaf: false, Left: newLeft1, Right: id.Right}, + &ID{IsLeaf: false, Left: newLeft2, Right: &ID{IsLeaf: true, Value: 0}} + } + + // Both have IDs, split + return &ID{IsLeaf: false, Left: id.Left, Right: &ID{IsLeaf: true, Value: 0}}, + &ID{IsLeaf: false, Left: &ID{IsLeaf: true, Value: 0}, Right: id.Right} +} + +// Join merges two IDs +func Join(id1, id2 *ID) *ID { + if id1.IsLeaf && id1.Value == 0 { + return id2 + } + if id2.IsLeaf && id2.Value == 0 { + return id1 + } + if id1.IsLeaf && id2.IsLeaf && id1.Value == 1 && id2.Value == 1 { + return &ID{IsLeaf: true, Value: 1} + } + + // Normalize to non-leaf + left1 := id1.Left + right1 := id1.Right + left2 := id2.Left + right2 := id2.Right + + if id1.IsLeaf { + left1 = id1 + right1 = id1 + } + if id2.IsLeaf { + left2 = id2 + right2 = id2 + } + + newLeft := Join(left1, left2) + newRight := Join(right1, right2) + + return normalize(&ID{IsLeaf: false, Left: newLeft, Right: newRight}) +} + +func normalize(id *ID) *ID { + if !id.IsLeaf { + if id.Left.IsLeaf && id.Right.IsLeaf && + id.Left.Value == id.Right.Value { + return &ID{IsLeaf: true, Value: id.Left.Value} + } + } + return id +} +``` + +### Stamp Operations + +```go +// NewStamp creates initial stamp (0) +func NewStamp() *Stamp { + return &Stamp{Base: 0} +} + +// Event increments the stamp for the given ID +func Event(id *ID, stamp *Stamp) *Stamp { + if id.IsLeaf { + if id.Value == 1 { + return &Stamp{Base: stamp.Base + 1} + } + return stamp // Cannot increment with zero ID + } + + // Non-leaf ID: fill where we have ID + if id.Left.IsLeaf && id.Left.Value == 1 { + // Have left ID, increment left + newLeft := Event(&ID{IsLeaf: true, Value: 1}, getLeft(stamp)) + return normalizeStamp(&Stamp{ + Base: stamp.Base, + Left: newLeft, + Right: getRight(stamp), + }) + } + if id.Right.IsLeaf && id.Right.Value == 1 { + newRight := Event(&ID{IsLeaf: true, Value: 1}, getRight(stamp)) + return normalizeStamp(&Stamp{ + Base: stamp.Base, + Left: getLeft(stamp), + Right: newRight, + }) + } + + // Both non-zero, choose lower side + leftMax := maxStamp(getLeft(stamp)) + rightMax := maxStamp(getRight(stamp)) + + if leftMax <= rightMax { + return normalizeStamp(&Stamp{ + Base: stamp.Base, + Left: Event(id.Left, getLeft(stamp)), + Right: getRight(stamp), + }) + } + return normalizeStamp(&Stamp{ + Base: stamp.Base, + Left: getLeft(stamp), + Right: Event(id.Right, getRight(stamp)), + }) +} + +func getLeft(s *Stamp) *Stamp { + if s.Left == nil { + return &Stamp{Base: 0} + } + return s.Left +} + +func getRight(s *Stamp) *Stamp { + if s.Right == nil { + return &Stamp{Base: 0} + } + return s.Right +} + +func maxStamp(s *Stamp) int { + if s.Left == nil && s.Right == nil { + return s.Base + } + left := 0 + right := 0 + if s.Left != nil { + left = maxStamp(s.Left) + } + if s.Right != nil { + right = maxStamp(s.Right) + } + max := left + if right > max { + max = right + } + return s.Base + max +} + +// JoinStamps merges two stamps +func JoinStamps(s1, s2 *Stamp) *Stamp { + // Take max at each level + base := s1.Base + if s2.Base > base { + base = s2.Base + } + + // Adjust for base difference + adj1 := s1.Base + adj2 := s2.Base + + return normalizeStamp(&Stamp{ + Base: base, + Left: joinStampsRecursive(s1.Left, s2.Left, adj1-base, adj2-base), + Right: joinStampsRecursive(s1.Right, s2.Right, adj1-base, adj2-base), + }) +} + +func normalizeStamp(s *Stamp) *Stamp { + if s.Left == nil && s.Right == nil { + return s + } + if s.Left != nil && s.Right != nil { + if s.Left.Base > 0 && s.Right.Base > 0 { + min := s.Left.Base + if s.Right.Base < min { + min = s.Right.Base + } + return &Stamp{ + Base: s.Base + min, + Left: &Stamp{Base: s.Left.Base - min, Left: s.Left.Left, Right: s.Left.Right}, + Right: &Stamp{Base: s.Right.Base - min, Left: s.Right.Left, Right: s.Right.Right}, + } + } + } + return s +} +``` + +## Hybrid Logical Clock Implementation + +```go +type HLC struct { + l int64 // logical component (physical time) + c int64 // counter + mu sync.Mutex +} + +func NewHLC() *HLC { + return &HLC{l: 0, c: 0} +} + +type HLCTimestamp struct { + L int64 + C int64 +} + +func (hlc *HLC) physicalTime() int64 { + return time.Now().UnixNano() +} + +// Now returns current HLC timestamp for local/send event +func (hlc *HLC) Now() HLCTimestamp { + hlc.mu.Lock() + defer hlc.mu.Unlock() + + pt := hlc.physicalTime() + + if pt > hlc.l { + hlc.l = pt + hlc.c = 0 + } else { + hlc.c++ + } + + return HLCTimestamp{L: hlc.l, C: hlc.c} +} + +// Update updates HLC based on received timestamp +func (hlc *HLC) Update(received HLCTimestamp) HLCTimestamp { + hlc.mu.Lock() + defer hlc.mu.Unlock() + + pt := hlc.physicalTime() + + if pt > hlc.l && pt > received.L { + hlc.l = pt + hlc.c = 0 + } else if received.L > hlc.l { + hlc.l = received.L + hlc.c = received.C + 1 + } else if hlc.l > received.L { + hlc.c++ + } else { // hlc.l == received.L + if received.C > hlc.c { + hlc.c = received.C + 1 + } else { + hlc.c++ + } + } + + return HLCTimestamp{L: hlc.l, C: hlc.c} +} + +// Compare compares two HLC timestamps +func (t1 HLCTimestamp) Compare(t2 HLCTimestamp) int { + if t1.L < t2.L { + return -1 + } + if t1.L > t2.L { + return 1 + } + if t1.C < t2.C { + return -1 + } + if t1.C > t2.C { + return 1 + } + return 0 +} +``` + +## Causal Broadcast Implementation + +```go +type CausalBroadcast struct { + vc *VectorClock + pending []PendingMessage + deliver func(Message) + mu sync.Mutex +} + +type PendingMessage struct { + Msg Message + Timestamp map[string]uint64 +} + +func NewCausalBroadcast(processID string, processes []string, deliver func(Message)) *CausalBroadcast { + return &CausalBroadcast{ + vc: NewVectorClock(processID, processes), + pending: make([]PendingMessage, 0), + deliver: deliver, + } +} + +// Broadcast sends a message to all processes +func (cb *CausalBroadcast) Broadcast(msg Message) map[string]uint64 { + cb.mu.Lock() + defer cb.mu.Unlock() + + timestamp := cb.vc.Send() + // Actual network broadcast would happen here + return timestamp +} + +// Receive handles an incoming message +func (cb *CausalBroadcast) Receive(msg Message, sender string, timestamp map[string]uint64) { + cb.mu.Lock() + defer cb.mu.Unlock() + + // Add to pending + cb.pending = append(cb.pending, PendingMessage{Msg: msg, Timestamp: timestamp}) + + // Try to deliver pending messages + cb.tryDeliver() +} + +func (cb *CausalBroadcast) tryDeliver() { + changed := true + for changed { + changed = false + + for i, pending := range cb.pending { + if cb.canDeliver(pending.Timestamp) { + // Deliver message + cb.vc.Receive(pending.Timestamp) + cb.deliver(pending.Msg) + + // Remove from pending + cb.pending = append(cb.pending[:i], cb.pending[i+1:]...) + changed = true + break + } + } + } +} + +func (cb *CausalBroadcast) canDeliver(msgVC map[string]uint64) bool { + currentVC := cb.vc.clocks + + for pid, msgTime := range msgVC { + if pid == cb.vc.self { + // Must be next expected from sender + if msgTime != currentVC[pid]+1 { + return false + } + } else { + // All other dependencies must be satisfied + if msgTime > currentVC[pid] { + return false + } + } + } + return true +} +``` diff --git a/.claude/skills/domain-driven-design/SKILL.md b/.claude/skills/domain-driven-design/SKILL.md new file mode 100644 index 00000000..6b7534ec --- /dev/null +++ b/.claude/skills/domain-driven-design/SKILL.md @@ -0,0 +1,166 @@ +--- +name: domain-driven-design +description: This skill should be used when designing software architecture, modeling domains, reviewing code for DDD compliance, identifying bounded contexts, designing aggregates, or discussing strategic and tactical DDD patterns. Provides comprehensive Domain-Driven Design principles, axioms, heuristics, and anti-patterns for building maintainable, domain-centric software systems. +--- + +# Domain-Driven Design + +## Overview + +Domain-Driven Design (DDD) is an approach to software development that centers the design on the core business domain. This skill provides principles, patterns, and heuristics for both strategic design (system boundaries and relationships) and tactical design (code-level patterns). + +## When to Apply This Skill + +- Designing new systems or features with complex business logic +- Identifying and defining bounded contexts +- Modeling aggregates, entities, and value objects +- Reviewing code for DDD pattern compliance +- Decomposing monoliths into services +- Establishing ubiquitous language with domain experts + +## Core Axioms + +### Axiom 1: The Domain is Supreme + +Software exists to solve domain problems. Technical decisions serve the domain, not vice versa. When technical elegance conflicts with domain clarity, domain clarity wins. + +### Axiom 2: Language Creates Reality + +The ubiquitous language shapes how teams think about the domain. Ambiguous language creates ambiguous software. Invest heavily in precise terminology. + +### Axiom 3: Boundaries Enable Autonomy + +Explicit boundaries (bounded contexts) allow teams to evolve independently. The cost of integration is worth the benefit of isolation. + +### Axiom 4: Models are Imperfect Approximations + +No model captures all domain complexity. Accept that models simplify reality. Refine models continuously as understanding deepens. + +## Strategic Design Quick Reference + +| Pattern | Purpose | Key Heuristic | +|---------|---------|---------------| +| **Bounded Context** | Define linguistic/model boundaries | One team, one language, one model | +| **Context Map** | Document context relationships | Make implicit integrations explicit | +| **Subdomain** | Classify domain areas by value | Core (invest), Supporting (adequate), Generic (outsource) | +| **Ubiquitous Language** | Shared vocabulary | If experts don't use the term, neither should code | + +For detailed strategic patterns, consult `references/strategic-patterns.md`. + +## Tactical Design Quick Reference + +| Pattern | Purpose | Key Heuristic | +|---------|---------|---------------| +| **Entity** | Identity-tracked object | "Same identity = same thing" regardless of attributes | +| **Value Object** | Immutable, identity-less | Equality by value, always immutable, self-validating | +| **Aggregate** | Consistency boundary | Small aggregates, reference by ID, one transaction = one aggregate | +| **Domain Event** | Record state changes | Past tense naming, immutable, contains all relevant data | +| **Repository** | Collection abstraction | One per aggregate root, domain-focused interface | +| **Domain Service** | Stateless operations | When logic doesn't belong to any single entity | +| **Factory** | Complex object creation | When construction logic is complex or variable | + +For detailed tactical patterns, consult `references/tactical-patterns.md`. + +## Essential Heuristics + +### Aggregate Design Heuristics + +1. **Protect business invariants inside aggregate boundaries** - If two pieces of data must be consistent, they belong in the same aggregate +2. **Design small aggregates** - Large aggregates cause concurrency issues and slow performance +3. **Reference other aggregates by identity only** - Never hold direct object references across aggregate boundaries +4. **Update one aggregate per transaction** - Eventual consistency across aggregates using domain events +5. **Aggregate roots are the only entry point** - External code never reaches inside to manipulate child entities + +### Bounded Context Heuristics + +1. **Linguistic boundaries** - When the same word means different things, you have different contexts +2. **Team boundaries** - One context per team enables autonomy +3. **Process boundaries** - Different business processes often indicate different contexts +4. **Data ownership** - Each context owns its data; no shared databases + +### Modeling Heuristics + +1. **Nouns → Entities or Value Objects** - Things with identity become entities; descriptive things become value objects +2. **Verbs → Domain Services or Methods** - Actions become methods on entities or stateless services +3. **Business rules → Invariants** - Rules the domain must always satisfy become aggregate invariants +4. **Events in domain expert language → Domain Events** - "When X happens" becomes a domain event + +## Decision Guides + +### Entity vs Value Object + +``` +Does this thing have a lifecycle and identity that matters? +├─ YES → Is identity based on an ID (not attributes)? +│ ├─ YES → Entity +│ └─ NO → Reconsider; might be Value Object with natural key +└─ NO → Value Object +``` + +### Where Does This Logic Belong? + +``` +Is this logic stateless? +├─ NO → Does it belong to a single aggregate? +│ ├─ YES → Method on the aggregate/entity +│ └─ NO → Reconsider aggregate boundaries +└─ YES → Does it coordinate multiple aggregates? + ├─ YES → Application Service + └─ NO → Does it represent a domain concept? + ├─ YES → Domain Service + └─ NO → Infrastructure Service +``` + +### Should This Be a Separate Bounded Context? + +``` +Do different stakeholders use different language for this? +├─ YES → Separate bounded context +└─ NO → Does a different team own this? + ├─ YES → Separate bounded context + └─ NO → Would a separate model reduce complexity? + ├─ YES → Consider separation (but weigh integration cost) + └─ NO → Keep in current context +``` + +## Anti-Patterns Overview + +| Anti-Pattern | Description | Fix | +|--------------|-------------|-----| +| **Anemic Domain Model** | Entities with only getters/setters | Move behavior into domain objects | +| **Big Ball of Mud** | No clear boundaries | Identify bounded contexts | +| **Smart UI** | Business logic in presentation layer | Extract domain layer | +| **Database-Driven Design** | Model follows database schema | Model follows domain, map to database | +| **Leaky Abstractions** | Infrastructure concerns in domain | Dependency inversion, ports and adapters | +| **God Aggregate** | One aggregate does everything | Split by invariant boundaries | +| **Premature Abstraction** | Abstracting before understanding | Concrete first, abstract when patterns emerge | + +For detailed anti-patterns and remediation, consult `references/anti-patterns.md`. + +## Implementation Checklist + +When implementing DDD in a codebase: + +- [ ] Ubiquitous language documented and used consistently in code +- [ ] Bounded contexts identified with clear boundaries +- [ ] Context map documenting integration patterns +- [ ] Aggregates designed small with clear invariants +- [ ] Entities have behavior, not just data +- [ ] Value objects are immutable and self-validating +- [ ] Domain events capture important state changes +- [ ] Repositories abstract persistence for aggregate roots +- [ ] No business logic in application services (orchestration only) +- [ ] No infrastructure concerns in domain layer + +## Resources + +### references/ + +- `strategic-patterns.md` - Detailed strategic DDD patterns including bounded contexts, context maps, subdomain classification, and ubiquitous language +- `tactical-patterns.md` - Detailed tactical DDD patterns including entities, value objects, aggregates, domain events, repositories, and services +- `anti-patterns.md` - Common DDD anti-patterns, how to identify them, and remediation strategies + +To search references for specific topics: +- Bounded contexts: `grep -i "bounded context" references/` +- Aggregate design: `grep -i "aggregate" references/` +- Value objects: `grep -i "value object" references/` diff --git a/.claude/skills/domain-driven-design/references/anti-patterns.md b/.claude/skills/domain-driven-design/references/anti-patterns.md new file mode 100644 index 00000000..62a45b3c --- /dev/null +++ b/.claude/skills/domain-driven-design/references/anti-patterns.md @@ -0,0 +1,853 @@ +# DDD Anti-Patterns + +This reference documents common anti-patterns encountered when implementing Domain-Driven Design, how to identify them, and remediation strategies. + +## Anemic Domain Model + +### Description + +Entities that are mere data containers with getters and setters, while all business logic lives in "service" classes. The domain model looks like a relational database schema mapped to objects. + +### Symptoms + +- Entities with only get/set methods and no behavior +- Service classes with methods like `orderService.calculateTotal(order)` +- Business rules scattered across multiple services +- Heavy use of DTOs that mirror entity structure +- "Transaction scripts" in application services + +### Example + +```typescript +// ANTI-PATTERN: Anemic domain model +class Order { + id: string; + customerId: string; + items: OrderItem[]; + status: string; + total: number; + + // Only data access, no behavior + getId(): string { return this.id; } + setStatus(status: string): void { this.status = status; } + getItems(): OrderItem[] { return this.items; } + setTotal(total: number): void { this.total = total; } +} + +class OrderService { + // All logic external to the entity + calculateTotal(order: Order): number { + let total = 0; + for (const item of order.getItems()) { + total += item.price * item.quantity; + } + order.setTotal(total); + return total; + } + + canShip(order: Order): boolean { + return order.status === 'PAID' && order.getItems().length > 0; + } + + ship(order: Order, trackingNumber: string): void { + if (!this.canShip(order)) { + throw new Error('Cannot ship order'); + } + order.setStatus('SHIPPED'); + order.trackingNumber = trackingNumber; + } +} +``` + +### Remediation + +```typescript +// CORRECT: Rich domain model +class Order { + private _id: OrderId; + private _items: OrderItem[]; + private _status: OrderStatus; + + // Behavior lives in the entity + get total(): Money { + return this._items.reduce( + (sum, item) => sum.add(item.subtotal()), + Money.zero() + ); + } + + canShip(): boolean { + return this._status === OrderStatus.Paid && this._items.length > 0; + } + + ship(trackingNumber: TrackingNumber): void { + if (!this.canShip()) { + throw new OrderNotShippableError(this._id, this._status); + } + this._status = OrderStatus.Shipped; + this._trackingNumber = trackingNumber; + } + + addItem(item: OrderItem): void { + this.ensureCanModify(); + this._items.push(item); + } +} + +// Application service is thin - only orchestration +class OrderApplicationService { + async shipOrder(orderId: OrderId, trackingNumber: TrackingNumber): Promise { + const order = await this.orderRepository.findById(orderId); + order.ship(trackingNumber); // Domain logic in entity + await this.orderRepository.save(order); + } +} +``` + +### Root Causes + +- Developers treating objects as data structures +- Thinking in terms of database tables +- Copying patterns from CRUD applications +- Misunderstanding "service" to mean "all logic goes here" + +## God Aggregate + +### Description + +An aggregate that has grown to encompass too much. It handles multiple concerns, has many child entities, and becomes a performance and concurrency bottleneck. + +### Symptoms + +- Aggregates with 10+ child entity types +- Long load times due to eager loading everything +- Frequent optimistic concurrency conflicts +- Methods that only touch a small subset of the aggregate +- Difficulty reasoning about invariants + +### Example + +```typescript +// ANTI-PATTERN: God aggregate +class Customer { + private _id: CustomerId; + private _profile: CustomerProfile; + private _addresses: Address[]; + private _paymentMethods: PaymentMethod[]; + private _orders: Order[]; // History of all orders! + private _wishlist: WishlistItem[]; + private _reviews: Review[]; + private _loyaltyPoints: LoyaltyAccount; + private _preferences: Preferences; + private _notifications: Notification[]; + private _supportTickets: SupportTicket[]; + + // Loading this customer loads EVERYTHING + // Updating preferences causes concurrency conflict with order placement +} +``` + +### Remediation + +```typescript +// CORRECT: Small, focused aggregates +class Customer { + private _id: CustomerId; + private _profile: CustomerProfile; + private _defaultAddressId: AddressId; + private _membershipTier: MembershipTier; +} + +class CustomerAddressBook { + private _customerId: CustomerId; + private _addresses: Address[]; +} + +class ShoppingCart { + private _customerId: CustomerId; // Reference by ID + private _items: CartItem[]; +} + +class Wishlist { + private _customerId: CustomerId; // Reference by ID + private _items: WishlistItem[]; +} + +class LoyaltyAccount { + private _customerId: CustomerId; // Reference by ID + private _points: Points; + private _transactions: LoyaltyTransaction[]; +} +``` + +### Identification Heuristic + +Ask: "Do all these things need to be immediately consistent?" If the answer is no, they probably belong in separate aggregates. + +## Aggregate Reference Violation + +### Description + +Aggregates holding direct object references to other aggregates instead of referencing by identity. Creates implicit coupling and makes it impossible to reason about transactional boundaries. + +### Symptoms + +- Navigation from one aggregate to another: `order.customer.address` +- Loading an aggregate brings in connected aggregates +- Unclear what gets saved when calling `save()` +- Difficulty implementing eventual consistency + +### Example + +```typescript +// ANTI-PATTERN: Direct reference +class Order { + private customer: Customer; // Direct reference! + private shippingAddress: Address; + + getCustomerEmail(): string { + return this.customer.email; // Navigating through! + } + + validate(): void { + // Touching another aggregate's data + if (this.customer.creditLimit < this.total) { + throw new Error('Credit limit exceeded'); + } + } +} +``` + +### Remediation + +```typescript +// CORRECT: Reference by identity +class Order { + private _customerId: CustomerId; // ID only! + private _shippingAddress: Address; // Value object copied at order time + + // If customer data is needed, it must be explicitly loaded + static create( + customerId: CustomerId, + shippingAddress: Address, + creditLimit: Money // Passed in, not navigated to + ): Order { + return new Order(customerId, shippingAddress, creditLimit); + } +} + +// Application service coordinates loading if needed +class OrderApplicationService { + async getOrderWithCustomerDetails(orderId: OrderId): Promise { + const order = await this.orderRepository.findById(orderId); + const customer = await this.customerRepository.findById(order.customerId); + + return new OrderDetails(order, customer); + } +} +``` + +## Smart UI + +### Description + +Business logic embedded directly in the user interface layer. Controllers, presenters, or UI components contain domain rules. + +### Symptoms + +- Validation logic in form handlers +- Business calculations in controllers +- State machines in UI components +- Domain rules duplicated across different UI views +- "If we change the UI framework, we lose the business logic" + +### Example + +```typescript +// ANTI-PATTERN: Smart UI +class OrderController { + submitOrder(request: Request): Response { + const cart = request.body; + + // Business logic in controller! + let total = 0; + for (const item of cart.items) { + total += item.price * item.quantity; + } + + // Discount rules in controller! + if (cart.items.length > 10) { + total *= 0.9; // 10% bulk discount + } + + if (total > 1000 && !this.hasValidPaymentMethod(cart.customerId)) { + return Response.error('Orders over $1000 require verified payment'); + } + + // More business rules... + const order = { + customerId: cart.customerId, + items: cart.items, + total: total, + status: 'PENDING' + }; + + this.database.insert('orders', order); + return Response.ok(order); + } +} +``` + +### Remediation + +```typescript +// CORRECT: UI delegates to domain +class OrderController { + submitOrder(request: Request): Response { + const command = new PlaceOrderCommand( + request.body.customerId, + request.body.items + ); + + try { + const orderId = this.orderApplicationService.placeOrder(command); + return Response.ok({ orderId }); + } catch (error) { + if (error instanceof DomainError) { + return Response.badRequest(error.message); + } + throw error; + } + } +} + +// Domain logic in domain layer +class Order { + private calculateTotal(): Money { + const subtotal = this._items.reduce( + (sum, item) => sum.add(item.subtotal()), + Money.zero() + ); + return this._discountPolicy.apply(subtotal, this._items.length); + } +} + +class BulkDiscountPolicy implements DiscountPolicy { + apply(subtotal: Money, itemCount: number): Money { + if (itemCount > 10) { + return subtotal.multiply(0.9); + } + return subtotal; + } +} +``` + +## Database-Driven Design + +### Description + +The domain model is derived from the database schema rather than from domain concepts. Tables become classes; foreign keys become object references; database constraints become business rules. + +### Symptoms + +- Class names match table names exactly +- Foreign key relationships drive object graph +- ID fields everywhere, even where identity doesn't matter +- `nullable` database columns drive optional properties +- Domain model changes require database migration first + +### Example + +```typescript +// ANTI-PATTERN: Database-driven model +// Mirrors database schema exactly +class orders { + order_id: number; + customer_id: number; + order_date: Date; + status_cd: string; + shipping_address_id: number; + billing_address_id: number; + total_amt: number; + tax_amt: number; + created_ts: Date; + updated_ts: Date; +} + +class order_items { + order_item_id: number; + order_id: number; + product_id: number; + quantity: number; + unit_price: number; + discount_pct: number; +} +``` + +### Remediation + +```typescript +// CORRECT: Domain-driven model +class Order { + private readonly _id: OrderId; + private _status: OrderStatus; + private _items: OrderItem[]; + private _shippingAddress: Address; // Value object, not FK + private _billingAddress: Address; + + // Domain behavior, not database structure + get total(): Money { + return this._items.reduce( + (sum, item) => sum.add(item.lineTotal()), + Money.zero() + ); + } + + ship(trackingNumber: TrackingNumber): void { + // Business logic + } +} + +// Mapping is infrastructure concern +class OrderRepository { + async save(order: Order): Promise { + // Map rich domain object to database tables + await this.db.query( + 'INSERT INTO orders (id, status, shipping_street, shipping_city...) VALUES (...)' + ); + } +} +``` + +### Key Principle + +The domain model reflects how domain experts think, not how data is stored. Persistence is an infrastructure detail. + +## Leaky Abstractions + +### Description + +Infrastructure concerns bleeding into the domain layer. Domain objects depend on frameworks, databases, or external services. + +### Symptoms + +- Domain entities with ORM decorators +- Repository interfaces returning database-specific types +- Domain services making HTTP calls +- Framework annotations on domain objects +- `import { Entity } from 'typeorm'` in domain layer + +### Example + +```typescript +// ANTI-PATTERN: Infrastructure leaking into domain +import { Entity, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { IsEmail, IsNotEmpty } from 'class-validator'; + +@Entity('customers') // ORM in domain! +export class Customer { + @PrimaryColumn() + id: string; + + @Column() + @IsNotEmpty() // Validation framework in domain! + name: string; + + @Column() + @IsEmail() + email: string; + + @ManyToOne(() => Subscription) // ORM relationship in domain! + subscription: Subscription; +} + +// Domain service calling external API directly +class ShippingCostService { + async calculateCost(order: Order): Promise { + // HTTP call in domain! + const response = await fetch('https://shipping-api.com/rates', { + body: JSON.stringify(order) + }); + return response.json().cost; + } +} +``` + +### Remediation + +```typescript +// CORRECT: Clean domain layer +// Domain object - no framework dependencies +class Customer { + private constructor( + private readonly _id: CustomerId, + private readonly _name: CustomerName, + private readonly _email: Email + ) {} + + static create(name: string, email: string): Customer { + return new Customer( + CustomerId.generate(), + CustomerName.create(name), // Self-validating value object + Email.create(email) // Self-validating value object + ); + } +} + +// Port (interface) defined in domain +interface ShippingRateProvider { + getRate(destination: Address, weight: Weight): Promise; +} + +// Domain service uses port +class ShippingCostCalculator { + constructor(private rateProvider: ShippingRateProvider) {} + + async calculate(order: Order): Promise { + return this.rateProvider.getRate( + order.shippingAddress, + order.totalWeight() + ); + } +} + +// Adapter (infrastructure) implements port +class ShippingApiRateProvider implements ShippingRateProvider { + async getRate(destination: Address, weight: Weight): Promise { + const response = await fetch('https://shipping-api.com/rates', { + body: JSON.stringify({ destination, weight }) + }); + const data = await response.json(); + return Money.of(data.cost, Currency.USD); + } +} +``` + +## Shared Database + +### Description + +Multiple bounded contexts accessing the same database tables. Changes in one context break others. No clear data ownership. + +### Symptoms + +- Multiple services querying the same tables +- Fear of schema changes because "something else might break" +- Unclear which service is authoritative for data +- Cross-context joins in queries +- Database triggers coordinating contexts + +### Example + +```typescript +// ANTI-PATTERN: Shared database +// Sales context +class SalesOrderService { + async getOrder(orderId: string) { + return this.db.query(` + SELECT o.*, c.name, c.email, p.name as product_name + FROM orders o + JOIN customers c ON o.customer_id = c.id + JOIN products p ON o.product_id = p.id + WHERE o.id = ? + `, [orderId]); + } +} + +// Shipping context - same tables! +class ShippingService { + async getOrdersToShip() { + return this.db.query(` + SELECT o.*, c.address + FROM orders o + JOIN customers c ON o.customer_id = c.id + WHERE o.status = 'PAID' + `); + } + + async markShipped(orderId: string) { + // Directly modifying shared table + await this.db.query( + "UPDATE orders SET status = 'SHIPPED' WHERE id = ?", + [orderId] + ); + } +} +``` + +### Remediation + +```typescript +// CORRECT: Each context owns its data +// Sales context - owns order creation +class SalesOrderRepository { + async save(order: SalesOrder): Promise { + await this.salesDb.query('INSERT INTO sales_orders...'); + + // Publish event for other contexts + await this.eventPublisher.publish( + new OrderPlaced(order.id, order.customerId, order.items) + ); + } +} + +// Shipping context - owns its projection +class ShippingOrderProjection { + // Handles events to build local projection + async handleOrderPlaced(event: OrderPlaced): Promise { + await this.shippingDb.query(` + INSERT INTO shipments (order_id, customer_id, status) + VALUES (?, ?, 'PENDING') + `, [event.orderId, event.customerId]); + } +} + +class ShipmentRepository { + async findPendingShipments(): Promise { + // Queries only shipping context's data + return this.shippingDb.query( + "SELECT * FROM shipments WHERE status = 'PENDING'" + ); + } +} +``` + +## Premature Abstraction + +### Description + +Creating abstractions, interfaces, and frameworks before understanding the problem space. Often justified as "flexibility for the future." + +### Symptoms + +- Interfaces with single implementations +- Generic frameworks solving hypothetical problems +- Heavy use of design patterns without clear benefit +- Configuration systems for things that never change +- "We might need this someday" + +### Example + +```typescript +// ANTI-PATTERN: Premature abstraction +interface IOrderProcessor { + process(order: TOrder): Promise; +} + +interface IOrderValidator { + validate(order: TOrder): ValidationResult; +} + +interface IOrderPersister { + persist(order: TOrder): Promise; +} + +abstract class AbstractOrderProcessor + implements IOrderProcessor { + + constructor( + protected validator: IOrderValidator, + protected persister: IOrderPersister, + protected notifier: INotificationService, + protected logger: ILogger, + protected metrics: IMetricsCollector + ) {} + + async process(order: TOrder): Promise { + this.logger.log('Processing order'); + this.metrics.increment('orders.processed'); + + const validation = this.validator.validate(order); + if (!validation.isValid) { + throw new ValidationException(validation.errors); + } + + const result = await this.doProcess(order); + await this.persister.persist(order); + await this.notifier.notify(order); + + return result; + } + + protected abstract doProcess(order: TOrder): Promise; +} + +// Only one concrete implementation ever created +class StandardOrderProcessor extends AbstractOrderProcessor { + protected async doProcess(order: Order): Promise { + // The actual logic is trivial + return new OrderResult(order.id); + } +} +``` + +### Remediation + +```typescript +// CORRECT: Concrete first, abstract when patterns emerge +class OrderService { + async placeOrder(command: PlaceOrderCommand): Promise { + const order = Order.create(command); + + if (!order.isValid()) { + throw new InvalidOrderError(order.validationErrors()); + } + + await this.orderRepository.save(order); + + return order.id; + } +} + +// Only add abstraction when you have multiple implementations +// and understand the variation points +``` + +### Heuristic + +Wait until you have three similar implementations before abstracting. The right abstraction will be obvious then. + +## Big Ball of Mud + +### Description + +A system without clear architectural boundaries. Everything depends on everything. Changes ripple unpredictably. + +### Symptoms + +- No clear module boundaries +- Circular dependencies +- Any change might break anything +- "Only Bob understands how this works" +- Integration tests are the only reliable tests +- Fear of refactoring + +### Identification + +``` +# Circular dependency example +OrderService → CustomerService → PaymentService → OrderService +``` + +### Remediation Strategy + +1. **Identify implicit contexts** - Find clusters of related functionality +2. **Define explicit boundaries** - Create modules/packages with clear interfaces +3. **Break cycles** - Introduce events or shared kernel for circular dependencies +4. **Enforce boundaries** - Use architectural tests, linting rules + +```typescript +// Step 1: Identify boundaries +// sales/ - order creation, pricing +// fulfillment/ - shipping, tracking +// customer/ - customer management +// shared/ - shared kernel (Money, Address) + +// Step 2: Define public interfaces +// sales/index.ts +export { OrderService } from './application/OrderService'; +export { OrderPlaced, OrderCancelled } from './domain/events'; +// Internal types not exported + +// Step 3: Break cycles with events +class OrderService { + async placeOrder(command: PlaceOrderCommand): Promise { + const order = Order.create(command); + await this.orderRepository.save(order); + + // Instead of calling PaymentService directly + await this.eventPublisher.publish(new OrderPlaced(order)); + + return order.id; + } +} + +class PaymentEventHandler { + async handleOrderPlaced(event: OrderPlaced): Promise { + await this.paymentService.collectPayment(event.orderId, event.total); + } +} +``` + +## CRUD-Driven Development + +### Description + +Treating all domain operations as Create, Read, Update, Delete operations. Loses domain intent and behavior. + +### Symptoms + +- Endpoints like `PUT /orders/{id}` that accept any field changes +- Service methods like `updateOrder(orderId, updates)` +- Domain events named `OrderUpdated` instead of `OrderShipped` +- No validation of state transitions +- Business operations hidden behind generic updates + +### Example + +```typescript +// ANTI-PATTERN: CRUD-driven +class OrderController { + @Put('/orders/:id') + async updateOrder(id: string, body: Partial) { + // Any field can be updated! + return this.orderService.update(id, body); + } +} + +class OrderService { + async update(id: string, updates: Partial): Promise { + const order = await this.repo.findById(id); + Object.assign(order, updates); // Blindly apply updates + return this.repo.save(order); + } +} +``` + +### Remediation + +```typescript +// CORRECT: Intent-revealing operations +class OrderController { + @Post('/orders/:id/ship') + async shipOrder(id: string, body: ShipOrderRequest) { + return this.orderService.ship(id, body.trackingNumber); + } + + @Post('/orders/:id/cancel') + async cancelOrder(id: string, body: CancelOrderRequest) { + return this.orderService.cancel(id, body.reason); + } +} + +class OrderService { + async ship(orderId: OrderId, trackingNumber: TrackingNumber): Promise { + const order = await this.repo.findById(orderId); + order.ship(trackingNumber); // Domain logic with validation + await this.repo.save(order); + await this.publish(new OrderShipped(orderId, trackingNumber)); + } + + async cancel(orderId: OrderId, reason: CancellationReason): Promise { + const order = await this.repo.findById(orderId); + order.cancel(reason); // Validates cancellation is allowed + await this.repo.save(order); + await this.publish(new OrderCancelled(orderId, reason)); + } +} +``` + +## Summary: Detection Checklist + +| Anti-Pattern | Key Question | +|--------------|--------------| +| Anemic Domain Model | Do entities have behavior or just data? | +| God Aggregate | Does everything need immediate consistency? | +| Aggregate Reference Violation | Are aggregates holding other aggregates? | +| Smart UI | Would changing UI framework lose business logic? | +| Database-Driven Design | Does model match tables or domain concepts? | +| Leaky Abstractions | Does domain code import infrastructure? | +| Shared Database | Do multiple contexts write to same tables? | +| Premature Abstraction | Are there interfaces with single implementations? | +| Big Ball of Mud | Can any change break anything? | +| CRUD-Driven Development | Are operations generic updates or domain intents? | diff --git a/.claude/skills/domain-driven-design/references/strategic-patterns.md b/.claude/skills/domain-driven-design/references/strategic-patterns.md new file mode 100644 index 00000000..bcf132dd --- /dev/null +++ b/.claude/skills/domain-driven-design/references/strategic-patterns.md @@ -0,0 +1,358 @@ +# Strategic DDD Patterns + +Strategic DDD patterns address the large-scale structure of a system: how to divide it into bounded contexts, how those contexts relate, and how to prioritize investment across subdomains. + +## Bounded Context + +### Definition + +A Bounded Context is an explicit boundary within which a domain model exists. Inside the boundary, all terms have specific, unambiguous meanings. The same term may mean different things in different bounded contexts. + +### Why It Matters + +- **Linguistic clarity** - "Customer" in Sales means something different than "Customer" in Shipping +- **Model isolation** - Changes to one model don't cascade across the system +- **Team autonomy** - Teams can work independently within their context +- **Focused complexity** - Each context solves one set of problems well + +### Identification Heuristics + +1. **Language divergence** - When stakeholders use the same word differently, there's a context boundary +2. **Department boundaries** - Organizational structure often mirrors domain structure +3. **Process boundaries** - End-to-end business processes often define context edges +4. **Data ownership** - Who is the authoritative source for this data? +5. **Change frequency** - Parts that change together should stay together + +### Example: E-Commerce Platform + +| Context | "Order" means... | "Product" means... | +|---------|------------------|-------------------| +| **Catalog** | N/A | Displayable item with description, images, categories | +| **Inventory** | N/A | Stock keeping unit with quantity and location | +| **Sales** | Shopping cart ready for checkout | Line item with price | +| **Fulfillment** | Shipment to be picked and packed | Physical item to ship | +| **Billing** | Invoice to collect payment | Taxable good | + +### Implementation Patterns + +#### Separate Deployables +Each bounded context as its own service/application. + +``` +catalog-service/ +├── src/domain/Product.ts +└── src/infrastructure/CatalogRepository.ts + +sales-service/ +├── src/domain/Product.ts # Different model! +└── src/domain/Order.ts +``` + +#### Module Boundaries +Bounded contexts as modules within a monolith. + +``` +src/ +├── catalog/ +│ └── domain/Product.ts +├── sales/ +│ └── domain/Product.ts # Different model! +└── shared/ + └── kernel/Money.ts # Shared kernel +``` + +## Context Map + +### Definition + +A Context Map is a visual and documented representation of how bounded contexts relate to each other. It makes integration patterns explicit. + +### Integration Patterns + +#### Partnership + +Two contexts develop together with mutual dependencies. Changes are coordinated. + +``` +┌─────────────┐ Partnership ┌─────────────┐ +│ Catalog │◄──────────────────►│ Inventory │ +└─────────────┘ └─────────────┘ +``` + +**Use when**: Two teams must succeed or fail together. + +#### Shared Kernel + +A small, shared model that multiple contexts depend on. Changes require agreement from all consumers. + +``` +┌─────────────┐ ┌─────────────┐ +│ Sales │ │ Billing │ +└──────┬──────┘ └──────┬──────┘ + │ │ + └─────────► Money ◄──────────────┘ + (shared kernel) +``` + +**Use when**: Core concepts genuinely need the same model. +**Danger**: Creates coupling. Keep shared kernels minimal. + +#### Customer-Supplier + +Upstream context (supplier) provides data/services; downstream context (customer) consumes. Supplier considers customer needs. + +``` +┌─────────────┐ ┌─────────────┐ +│ Catalog │───── supplies ────►│ Sales │ +│ (upstream) │ │ (downstream)│ +└─────────────┘ └─────────────┘ +``` + +**Use when**: One context clearly serves another, and the supplier is responsive. + +#### Conformist + +Downstream adopts upstream's model without negotiation. Upstream doesn't accommodate downstream needs. + +``` +┌─────────────┐ ┌─────────────┐ +│ External │───── dictates ────►│ Our App │ +│ API │ │ (conformist)│ +└─────────────┘ └─────────────┘ +``` + +**Use when**: Upstream won't change (third-party API), and their model is acceptable. + +#### Anti-Corruption Layer (ACL) + +Translation layer that protects a context from external models. Transforms data at the boundary. + +``` +┌─────────────┐ ┌───────┐ ┌─────────────┐ +│ Legacy │───────►│ ACL │───────►│ New System │ +│ System │ └───────┘ └─────────────┘ +``` + +**Use when**: Upstream model would pollute downstream; translation is worth the cost. + +```typescript +// Anti-Corruption Layer example +class LegacyOrderAdapter { + constructor(private legacyApi: LegacyOrderApi) {} + + translateOrder(legacyOrder: LegacyOrder): Order { + return new Order({ + id: OrderId.from(legacyOrder.order_num), + customer: this.translateCustomer(legacyOrder.cust_data), + items: legacyOrder.line_items.map(this.translateLineItem), + // Transform legacy status codes to domain concepts + status: this.mapStatus(legacyOrder.stat_cd), + }); + } + + private mapStatus(legacyCode: string): OrderStatus { + const mapping: Record = { + 'OP': OrderStatus.Open, + 'SH': OrderStatus.Shipped, + 'CL': OrderStatus.Closed, + }; + return mapping[legacyCode] ?? OrderStatus.Unknown; + } +} +``` + +#### Open Host Service + +A context provides a well-defined protocol/API for others to consume. + +``` + ┌─────────────┐ + ┌──────────►│ Reports │ + │ └─────────────┘ +┌───────┴───────┐ ┌─────────────┐ +│ Catalog API │──►│ Search │ +│ (open host) │ └─────────────┘ +└───────┬───────┘ ┌─────────────┐ + └──────────►│ Partner │ + └─────────────┘ +``` + +**Use when**: Multiple downstream contexts need access; worth investing in a stable API. + +#### Published Language + +A shared language format (schema) for communication between contexts. Often combined with Open Host Service. + +Examples: JSON schemas, Protocol Buffers, GraphQL schemas, industry standards (HL7 for healthcare). + +#### Separate Ways + +Contexts have no integration. Each solves its needs independently. + +**Use when**: Integration cost exceeds benefit; duplication is acceptable. + +### Context Map Notation + +``` +┌───────────────────────────────────────────────────────────────┐ +│ CONTEXT MAP │ +├───────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ Partnership ┌─────────┐ │ +│ │ Sales │◄────────────────────────────►│Inventory│ │ +│ │ (U,D) │ │ (U,D) │ │ +│ └────┬────┘ └────┬────┘ │ +│ │ │ │ +│ │ Customer/Supplier │ │ +│ ▼ │ │ +│ ┌─────────┐ │ │ +│ │ Billing │◄──────────────────────────────────┘ │ +│ │ (D) │ Conformist │ +│ └─────────┘ │ +│ │ +│ Legend: U = Upstream, D = Downstream │ +└───────────────────────────────────────────────────────────────┘ +``` + +## Subdomain Classification + +### Core Domain + +The essential differentiator. This is where competitive advantage lives. + +**Characteristics**: +- Unique to this business +- Complex, requires deep expertise +- Frequently changing as business evolves +- Worth significant investment + +**Strategy**: Build in-house with best talent. Invest heavily in modeling. + +### Supporting Subdomain + +Necessary for the business but not a differentiator. + +**Characteristics**: +- Important but not unique +- Moderate complexity +- Changes less frequently +- Custom implementation needed + +**Strategy**: Build with adequate (not exceptional) investment. May outsource. + +### Generic Subdomain + +Solved problems with off-the-shelf solutions. + +**Characteristics**: +- Common across industries +- Well-understood solutions exist +- Rarely changes +- Not a differentiator + +**Strategy**: Buy or use open-source. Don't reinvent. + +### Example: E-Commerce Platform + +| Subdomain | Type | Strategy | +|-----------|------|----------| +| Product Recommendation Engine | Core | In-house, top talent | +| Inventory Management | Supporting | Build, adequate investment | +| Payment Processing | Generic | Third-party (Stripe, etc.) | +| User Authentication | Generic | Third-party or standard library | +| Shipping Logistics | Supporting | Build or integrate vendor | +| Customer Analytics | Core | In-house, strategic investment | + +## Ubiquitous Language + +### Definition + +A common language shared by developers and domain experts. It appears in conversations, documentation, and code. + +### Building Ubiquitous Language + +1. **Listen to experts** - Use their terminology, not technical jargon +2. **Challenge vague terms** - "Process the order" → What exactly happens? +3. **Document glossary** - Maintain a living dictionary +4. **Enforce in code** - Class and method names use the language +5. **Refine continuously** - Language evolves with understanding + +### Language in Code + +```typescript +// Bad: Technical terms +class OrderProcessor { + handleOrderCreation(data: OrderData): void { + this.validateData(data); + this.persistToDatabase(data); + this.sendNotification(data); + } +} + +// Good: Ubiquitous language +class OrderTaker { + placeOrder(cart: ShoppingCart): PlacedOrder { + const order = cart.checkout(); + order.confirmWith(this.paymentGateway); + this.orderRepository.save(order); + this.domainEvents.publish(new OrderPlaced(order)); + return order; + } +} +``` + +### Glossary Example + +| Term | Definition | Context | +|------|------------|---------| +| **Order** | A confirmed purchase with payment collected | Sales | +| **Shipment** | Physical package(s) sent to fulfill an order | Fulfillment | +| **SKU** | Stock Keeping Unit; unique identifier for inventory | Inventory | +| **Cart** | Uncommitted collection of items a customer intends to buy | Sales | +| **Listing** | Product displayed for purchase in the catalog | Catalog | + +### Anti-Pattern: Technical Language Leakage + +```typescript +// Bad: Database terminology leaks into domain +order.setForeignKeyCustomerId(customerId); +order.persist(); + +// Bad: HTTP concerns leak into domain +order.deserializeFromJson(request.body); +order.setHttpStatus(200); + +// Good: Domain language only +order.placeFor(customer); +orderRepository.save(order); +``` + +## Strategic Design Decisions + +### When to Split a Bounded Context + +Split when: +- Different parts need to evolve at different speeds +- Different teams need ownership +- Model complexity is becoming unmanageable +- Language conflicts are emerging within the context + +Don't split when: +- Transaction boundaries would become awkward +- Integration cost outweighs isolation benefit +- Single team can handle the complexity + +### When to Merge Bounded Contexts + +Merge when: +- Integration overhead is excessive +- Same team owns both +- Models are converging naturally +- Separate contexts create artificial complexity + +### Dealing with Legacy Systems + +1. **Bubble context** - New bounded context with ACL to legacy +2. **Strangler fig** - Gradually replace legacy feature by feature +3. **Conformist** - Accept legacy model if acceptable +4. **Separate ways** - Rebuild independently, migrate data later diff --git a/.claude/skills/domain-driven-design/references/tactical-patterns.md b/.claude/skills/domain-driven-design/references/tactical-patterns.md new file mode 100644 index 00000000..ae543b1c --- /dev/null +++ b/.claude/skills/domain-driven-design/references/tactical-patterns.md @@ -0,0 +1,927 @@ +# Tactical DDD Patterns + +Tactical DDD patterns are code-level building blocks for implementing a rich domain model. They help express domain concepts in code that mirrors how domain experts think. + +## Entity + +### Definition + +An object defined by its identity rather than its attributes. Two entities with the same attribute values but different identities are different things. + +### Characteristics + +- Has a unique identifier that persists through state changes +- Identity established at creation, immutable thereafter +- Equality based on identity, not attribute values +- Has a lifecycle (created, modified, potentially deleted) +- Contains behavior relevant to the domain concept it represents + +### When to Use + +- The object represents something tracked over time +- "Is this the same one?" is a meaningful question +- The object needs to be referenced from other parts of the system +- State changes are important to track + +### Implementation + +```typescript +// Entity with identity and behavior +class Order { + private readonly _id: OrderId; + private _status: OrderStatus; + private _items: OrderItem[]; + private _shippingAddress: Address; + + constructor(id: OrderId, items: OrderItem[], shippingAddress: Address) { + this._id = id; + this._items = items; + this._shippingAddress = shippingAddress; + this._status = OrderStatus.Pending; + } + + get id(): OrderId { + return this._id; + } + + // Behavior, not just data access + confirm(): void { + if (this._items.length === 0) { + throw new EmptyOrderError(this._id); + } + this._status = OrderStatus.Confirmed; + } + + ship(trackingNumber: TrackingNumber): void { + if (this._status !== OrderStatus.Confirmed) { + throw new InvalidOrderStateError(this._id, this._status, 'ship'); + } + this._status = OrderStatus.Shipped; + // Domain event raised + } + + addItem(item: OrderItem): void { + if (this._status !== OrderStatus.Pending) { + throw new OrderModificationError(this._id); + } + this._items.push(item); + } + + // Identity-based equality + equals(other: Order): boolean { + return this._id.equals(other._id); + } +} + +// Strongly-typed identity +class OrderId { + constructor(private readonly value: string) { + if (!value || value.trim() === '') { + throw new InvalidOrderIdError(); + } + } + + equals(other: OrderId): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value; + } +} +``` + +### Entity vs Data Structure + +```typescript +// Bad: Anemic entity (data structure) +class Order { + id: string; + status: string; + items: Item[]; + + // Only getters/setters, no behavior +} + +// Good: Rich entity with behavior +class Order { + private _id: OrderId; + private _status: OrderStatus; + private _items: OrderItem[]; + + confirm(): void { /* enforces rules */ } + cancel(reason: CancellationReason): void { /* enforces rules */ } + addItem(item: OrderItem): void { /* enforces rules */ } +} +``` + +## Value Object + +### Definition + +An object defined entirely by its attributes. Two value objects with the same attributes are interchangeable. Has no identity. + +### Characteristics + +- Immutable - once created, never changes +- Equality based on attributes, not identity +- Self-validating - always in a valid state +- Side-effect free - methods return new instances +- Conceptually whole - attributes form a complete concept + +### When to Use + +- The concept has no lifecycle or identity +- "Are these the same?" means "do they have the same values?" +- Measurement, description, or quantification +- Combinations of attributes that belong together + +### Implementation + +```typescript +// Value Object: Money +class Money { + private constructor( + private readonly amount: number, + private readonly currency: Currency + ) {} + + // Factory method with validation + static of(amount: number, currency: Currency): Money { + if (amount < 0) { + throw new NegativeMoneyError(amount); + } + return new Money(amount, currency); + } + + // Immutable operations - return new instances + add(other: Money): Money { + this.ensureSameCurrency(other); + return Money.of(this.amount + other.amount, this.currency); + } + + subtract(other: Money): Money { + this.ensureSameCurrency(other); + return Money.of(this.amount - other.amount, this.currency); + } + + multiply(factor: number): Money { + return Money.of(this.amount * factor, this.currency); + } + + // Value-based equality + equals(other: Money): boolean { + return this.amount === other.amount && + this.currency.equals(other.currency); + } + + private ensureSameCurrency(other: Money): void { + if (!this.currency.equals(other.currency)) { + throw new CurrencyMismatchError(this.currency, other.currency); + } + } +} + +// Value Object: Address +class Address { + private constructor( + readonly street: string, + readonly city: string, + readonly postalCode: string, + readonly country: Country + ) {} + + static create(street: string, city: string, postalCode: string, country: Country): Address { + if (!street || !city || !postalCode) { + throw new InvalidAddressError(); + } + if (!country.validatePostalCode(postalCode)) { + throw new InvalidPostalCodeError(postalCode, country); + } + return new Address(street, city, postalCode, country); + } + + // Returns new instance with modified value + withStreet(newStreet: string): Address { + return Address.create(newStreet, this.city, this.postalCode, this.country); + } + + equals(other: Address): boolean { + return this.street === other.street && + this.city === other.city && + this.postalCode === other.postalCode && + this.country.equals(other.country); + } +} + +// Value Object: DateRange +class DateRange { + private constructor( + readonly start: Date, + readonly end: Date + ) {} + + static create(start: Date, end: Date): DateRange { + if (end < start) { + throw new InvalidDateRangeError(start, end); + } + return new DateRange(start, end); + } + + contains(date: Date): boolean { + return date >= this.start && date <= this.end; + } + + overlaps(other: DateRange): boolean { + return this.start <= other.end && this.end >= other.start; + } + + durationInDays(): number { + return Math.floor((this.end.getTime() - this.start.getTime()) / (1000 * 60 * 60 * 24)); + } +} +``` + +### Common Value Objects + +| Domain | Value Objects | +|--------|--------------| +| **E-commerce** | Money, Price, Quantity, SKU, Address, PhoneNumber | +| **Healthcare** | BloodPressure, Dosage, DateRange, PatientId | +| **Finance** | AccountNumber, IBAN, TaxId, Percentage | +| **Shipping** | Weight, Dimensions, TrackingNumber, PostalCode | +| **General** | Email, URL, PhoneNumber, Name, Coordinates | + +## Aggregate + +### Definition + +A cluster of entities and value objects with defined boundaries. Has an aggregate root entity that serves as the single entry point. External objects can only reference the root. + +### Characteristics + +- Defines a transactional consistency boundary +- Aggregate root is the only externally accessible object +- Enforces invariants across the cluster +- Loaded and saved as a unit +- Other aggregates referenced by identity only + +### Design Rules + +1. **Protect invariants** - All rules that must be consistent are inside the boundary +2. **Small aggregates** - Prefer single-entity aggregates; add children only when invariants require +3. **Reference by identity** - Never hold direct references to other aggregates +4. **Update one per transaction** - Eventual consistency between aggregates +5. **Design around invariants** - Identify what must be immediately consistent + +### Implementation + +```typescript +// Aggregate: Order (root) with OrderItems (child entities) +class Order { + private readonly _id: OrderId; + private _items: Map; + private _status: OrderStatus; + + // Invariant: Order total cannot exceed credit limit + private _creditLimit: Money; + + private constructor( + id: OrderId, + creditLimit: Money + ) { + this._id = id; + this._items = new Map(); + this._status = OrderStatus.Draft; + this._creditLimit = creditLimit; + } + + static create(id: OrderId, creditLimit: Money): Order { + return new Order(id, creditLimit); + } + + // All modifications go through aggregate root + addItem(productId: ProductId, quantity: Quantity, unitPrice: Money): void { + this.ensureCanModify(); + + const newItem = OrderItem.create(productId, quantity, unitPrice); + const projectedTotal = this.calculateTotalWith(newItem); + + // Invariant enforcement + if (projectedTotal.isGreaterThan(this._creditLimit)) { + throw new CreditLimitExceededError(projectedTotal, this._creditLimit); + } + + this._items.set(productId, newItem); + } + + removeItem(productId: ProductId): void { + this.ensureCanModify(); + this._items.delete(productId); + } + + updateItemQuantity(productId: ProductId, newQuantity: Quantity): void { + this.ensureCanModify(); + + const item = this._items.get(productId); + if (!item) { + throw new ItemNotFoundError(productId); + } + + const updatedItem = item.withQuantity(newQuantity); + const projectedTotal = this.calculateTotalWithUpdate(productId, updatedItem); + + if (projectedTotal.isGreaterThan(this._creditLimit)) { + throw new CreditLimitExceededError(projectedTotal, this._creditLimit); + } + + this._items.set(productId, updatedItem); + } + + submit(): OrderSubmitted { + if (this._items.size === 0) { + throw new EmptyOrderError(); + } + this._status = OrderStatus.Submitted; + + return new OrderSubmitted(this._id, this.total(), new Date()); + } + + // Read-only access to child entities + get items(): ReadonlyArray { + return Array.from(this._items.values()); + } + + total(): Money { + return this.items.reduce( + (sum, item) => sum.add(item.subtotal()), + Money.zero(Currency.USD) + ); + } + + private ensureCanModify(): void { + if (this._status !== OrderStatus.Draft) { + throw new OrderNotModifiableError(this._id, this._status); + } + } + + private calculateTotalWith(newItem: OrderItem): Money { + return this.total().add(newItem.subtotal()); + } + + private calculateTotalWithUpdate(productId: ProductId, updatedItem: OrderItem): Money { + const currentItem = this._items.get(productId)!; + return this.total().subtract(currentItem.subtotal()).add(updatedItem.subtotal()); + } +} + +// Child entity - only accessible through aggregate root +class OrderItem { + private constructor( + private readonly _productId: ProductId, + private _quantity: Quantity, + private readonly _unitPrice: Money + ) {} + + static create(productId: ProductId, quantity: Quantity, unitPrice: Money): OrderItem { + return new OrderItem(productId, quantity, unitPrice); + } + + get productId(): ProductId { return this._productId; } + get quantity(): Quantity { return this._quantity; } + get unitPrice(): Money { return this._unitPrice; } + + subtotal(): Money { + return this._unitPrice.multiply(this._quantity.value); + } + + withQuantity(newQuantity: Quantity): OrderItem { + return new OrderItem(this._productId, newQuantity, this._unitPrice); + } +} +``` + +### Aggregate Reference Patterns + +```typescript +// Bad: Direct object reference across aggregates +class Order { + private customer: Customer; // Holds the entire aggregate! +} + +// Good: Reference by identity +class Order { + private customerId: CustomerId; + + // If customer data needed, load separately + getCustomerAddress(customerRepository: CustomerRepository): Address { + const customer = customerRepository.findById(this.customerId); + return customer.shippingAddress; + } +} +``` + +## Domain Event + +### Definition + +A record of something significant that happened in the domain. Captures state changes that domain experts care about. + +### Characteristics + +- Named in past tense (OrderPlaced, PaymentReceived) +- Immutable - records historical fact +- Contains all relevant data about what happened +- Published after state change is committed +- May trigger reactions in same or different bounded contexts + +### When to Use + +- Domain experts talk about "when X happens, Y should happen" +- Need to communicate changes across aggregate boundaries +- Maintaining an audit trail +- Implementing eventual consistency +- Integration with other bounded contexts + +### Implementation + +```typescript +// Base domain event +abstract class DomainEvent { + readonly occurredAt: Date; + readonly eventId: string; + + constructor() { + this.occurredAt = new Date(); + this.eventId = generateUUID(); + } + + abstract get eventType(): string; +} + +// Specific domain events +class OrderPlaced extends DomainEvent { + constructor( + readonly orderId: OrderId, + readonly customerId: CustomerId, + readonly totalAmount: Money, + readonly items: ReadonlyArray + ) { + super(); + } + + get eventType(): string { + return 'order.placed'; + } +} + +class OrderShipped extends DomainEvent { + constructor( + readonly orderId: OrderId, + readonly trackingNumber: TrackingNumber, + readonly carrier: string, + readonly estimatedDelivery: Date + ) { + super(); + } + + get eventType(): string { + return 'order.shipped'; + } +} + +class PaymentReceived extends DomainEvent { + constructor( + readonly orderId: OrderId, + readonly amount: Money, + readonly paymentMethod: PaymentMethod, + readonly transactionId: string + ) { + super(); + } + + get eventType(): string { + return 'payment.received'; + } +} + +// Entity raising events +class Order { + private _domainEvents: DomainEvent[] = []; + + submit(): void { + // State change + this._status = OrderStatus.Submitted; + + // Raise event + this._domainEvents.push( + new OrderPlaced( + this._id, + this._customerId, + this.total(), + this.itemSnapshots() + ) + ); + } + + pullDomainEvents(): DomainEvent[] { + const events = [...this._domainEvents]; + this._domainEvents = []; + return events; + } +} + +// Event handler +class OrderPlacedHandler { + constructor( + private inventoryService: InventoryService, + private emailService: EmailService + ) {} + + async handle(event: OrderPlaced): Promise { + // Reserve inventory (different aggregate) + await this.inventoryService.reserveItems(event.items); + + // Send confirmation email + await this.emailService.sendOrderConfirmation( + event.customerId, + event.orderId, + event.totalAmount + ); + } +} +``` + +### Event Publishing Patterns + +```typescript +// Pattern 1: Collect and dispatch after save +class OrderApplicationService { + async placeOrder(command: PlaceOrderCommand): Promise { + const order = Order.create(command); + + await this.orderRepository.save(order); + + // Dispatch events after successful save + const events = order.pullDomainEvents(); + await this.eventDispatcher.dispatchAll(events); + + return order.id; + } +} + +// Pattern 2: Outbox pattern (reliable publishing) +class OrderApplicationService { + async placeOrder(command: PlaceOrderCommand): Promise { + await this.unitOfWork.transaction(async () => { + const order = Order.create(command); + await this.orderRepository.save(order); + + // Save events to outbox in same transaction + const events = order.pullDomainEvents(); + await this.outbox.saveEvents(events); + }); + + // Separate process publishes from outbox + return order.id; + } +} +``` + +## Repository + +### Definition + +Mediates between the domain and data mapping layers. Provides collection-like interface for accessing aggregates. + +### Characteristics + +- One repository per aggregate root +- Interface defined in domain layer, implementation in infrastructure +- Returns fully reconstituted aggregates +- Abstracts persistence concerns from domain + +### Interface Design + +```typescript +// Domain layer interface +interface OrderRepository { + findById(id: OrderId): Promise; + save(order: Order): Promise; + delete(order: Order): Promise; + + // Domain-specific queries + findPendingOrdersFor(customerId: CustomerId): Promise; + findOrdersToShipBefore(deadline: Date): Promise; +} + +// Infrastructure implementation +class PostgresOrderRepository implements OrderRepository { + constructor(private db: Database) {} + + async findById(id: OrderId): Promise { + const row = await this.db.query( + 'SELECT * FROM orders WHERE id = $1', + [id.toString()] + ); + + if (!row) return null; + + const items = await this.db.query( + 'SELECT * FROM order_items WHERE order_id = $1', + [id.toString()] + ); + + return this.reconstitute(row, items); + } + + async save(order: Order): Promise { + await this.db.transaction(async (tx) => { + await tx.query( + 'INSERT INTO orders (id, status, customer_id) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET status = $2', + [order.id.toString(), order.status, order.customerId.toString()] + ); + + // Save items + for (const item of order.items) { + await tx.query( + 'INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES ($1, $2, $3, $4) ON CONFLICT DO UPDATE...', + [order.id.toString(), item.productId.toString(), item.quantity.value, item.unitPrice.amount] + ); + } + }); + } + + private reconstitute(orderRow: any, itemRows: any[]): Order { + // Rebuild aggregate from persistence data + return Order.reconstitute({ + id: OrderId.from(orderRow.id), + status: OrderStatus[orderRow.status], + customerId: CustomerId.from(orderRow.customer_id), + items: itemRows.map(row => OrderItem.reconstitute({ + productId: ProductId.from(row.product_id), + quantity: Quantity.of(row.quantity), + unitPrice: Money.of(row.unit_price, Currency.USD) + })) + }); + } +} +``` + +### Repository vs DAO + +```typescript +// DAO: Data-centric, returns raw data +interface OrderDao { + findById(id: string): Promise; + findItems(orderId: string): Promise; + insert(row: OrderRow): Promise; +} + +// Repository: Domain-centric, returns aggregates +interface OrderRepository { + findById(id: OrderId): Promise; + save(order: Order): Promise; +} +``` + +## Domain Service + +### Definition + +Stateless operations that represent domain concepts but don't naturally belong to any entity or value object. + +### When to Use + +- The operation involves multiple aggregates +- The operation represents a domain concept +- Putting the operation on an entity would create awkward dependencies +- The operation is stateless + +### Examples + +```typescript +// Domain Service: Transfer money between accounts +class MoneyTransferService { + transfer( + from: Account, + to: Account, + amount: Money + ): TransferResult { + // Involves two aggregates + // Neither account should "own" this operation + + if (!from.canWithdraw(amount)) { + return TransferResult.insufficientFunds(); + } + + from.withdraw(amount); + to.deposit(amount); + + return TransferResult.success( + new MoneyTransferred(from.id, to.id, amount) + ); + } +} + +// Domain Service: Calculate shipping cost +class ShippingCostCalculator { + constructor( + private rateProvider: ShippingRateProvider + ) {} + + calculate( + items: OrderItem[], + destination: Address, + shippingMethod: ShippingMethod + ): Money { + const totalWeight = items.reduce( + (sum, item) => sum.add(item.weight), + Weight.zero() + ); + + const rate = this.rateProvider.getRate( + destination.country, + shippingMethod + ); + + return rate.calculateFor(totalWeight); + } +} + +// Domain Service: Check inventory availability +class InventoryAvailabilityService { + constructor( + private inventoryRepository: InventoryRepository + ) {} + + checkAvailability( + items: Array<{ productId: ProductId; quantity: Quantity }> + ): AvailabilityResult { + const unavailable: ProductId[] = []; + + for (const { productId, quantity } of items) { + const inventory = this.inventoryRepository.findByProductId(productId); + if (!inventory || !inventory.hasAvailable(quantity)) { + unavailable.push(productId); + } + } + + return unavailable.length === 0 + ? AvailabilityResult.allAvailable() + : AvailabilityResult.someUnavailable(unavailable); + } +} +``` + +### Domain Service vs Application Service + +```typescript +// Domain Service: Domain logic, domain types, stateless +class PricingService { + calculateDiscountedPrice(product: Product, customer: Customer): Money { + const basePrice = product.price; + const discount = customer.membershipLevel.discountPercentage; + return basePrice.applyDiscount(discount); + } +} + +// Application Service: Orchestration, use cases, transaction boundary +class OrderApplicationService { + constructor( + private orderRepository: OrderRepository, + private pricingService: PricingService, + private eventPublisher: EventPublisher + ) {} + + async createOrder(command: CreateOrderCommand): Promise { + const customer = await this.customerRepository.findById(command.customerId); + const order = Order.create(command.orderId, customer.id); + + for (const item of command.items) { + const product = await this.productRepository.findById(item.productId); + const price = this.pricingService.calculateDiscountedPrice(product, customer); + order.addItem(item.productId, item.quantity, price); + } + + await this.orderRepository.save(order); + await this.eventPublisher.publish(order.pullDomainEvents()); + + return order.id; + } +} +``` + +## Factory + +### Definition + +Encapsulates complex object or aggregate creation logic. Creates objects in a valid state. + +### When to Use + +- Construction logic is complex +- Multiple ways to create the same type of object +- Creation involves other objects or services +- Need to enforce invariants at creation time + +### Implementation + +```typescript +// Factory as static method +class Order { + static create(customerId: CustomerId, creditLimit: Money): Order { + return new Order( + OrderId.generate(), + customerId, + creditLimit, + OrderStatus.Draft, + [] + ); + } + + static reconstitute(data: OrderData): Order { + // For rebuilding from persistence + return new Order( + data.id, + data.customerId, + data.creditLimit, + data.status, + data.items + ); + } +} + +// Factory as separate class +class OrderFactory { + constructor( + private creditLimitService: CreditLimitService, + private idGenerator: IdGenerator + ) {} + + async createForCustomer(customerId: CustomerId): Promise { + const creditLimit = await this.creditLimitService.getLimit(customerId); + const orderId = this.idGenerator.generate(); + + return Order.create(orderId, customerId, creditLimit); + } + + createFromQuote(quote: Quote): Order { + const order = Order.create( + this.idGenerator.generate(), + quote.customerId, + quote.creditLimit + ); + + for (const item of quote.items) { + order.addItem(item.productId, item.quantity, item.agreedPrice); + } + + return order; + } +} + +// Builder pattern for complex construction +class OrderBuilder { + private customerId?: CustomerId; + private items: OrderItemData[] = []; + private shippingAddress?: Address; + private billingAddress?: Address; + + forCustomer(customerId: CustomerId): this { + this.customerId = customerId; + return this; + } + + withItem(productId: ProductId, quantity: Quantity, price: Money): this { + this.items.push({ productId, quantity, price }); + return this; + } + + shippingTo(address: Address): this { + this.shippingAddress = address; + return this; + } + + billingTo(address: Address): this { + this.billingAddress = address; + return this; + } + + build(): Order { + if (!this.customerId) throw new Error('Customer required'); + if (!this.shippingAddress) throw new Error('Shipping address required'); + if (this.items.length === 0) throw new Error('At least one item required'); + + const order = Order.create(this.customerId); + order.setShippingAddress(this.shippingAddress); + order.setBillingAddress(this.billingAddress ?? this.shippingAddress); + + for (const item of this.items) { + order.addItem(item.productId, item.quantity, item.price); + } + + return order; + } +} +``` diff --git a/.claude/skills/elliptic-curves/SKILL.md b/.claude/skills/elliptic-curves/SKILL.md new file mode 100644 index 00000000..82bab00f --- /dev/null +++ b/.claude/skills/elliptic-curves/SKILL.md @@ -0,0 +1,369 @@ +--- +name: elliptic-curves +description: This skill should be used when working with elliptic curve cryptography, implementing or debugging secp256k1 operations, understanding modular arithmetic and finite fields, or implementing signature schemes like ECDSA and Schnorr. Provides comprehensive knowledge of group theory foundations, curve mathematics, point multiplication algorithms, and cryptographic optimizations. +--- + +# Elliptic Curve Cryptography + +This skill provides deep knowledge of elliptic curve cryptography (ECC), with particular focus on the secp256k1 curve used in Bitcoin and Nostr, including the mathematical foundations and implementation considerations. + +## When to Use This Skill + +- Implementing or debugging elliptic curve operations +- Working with secp256k1, ECDSA, or Schnorr signatures +- Understanding modular arithmetic and finite field operations +- Optimizing cryptographic code for performance +- Analyzing security properties of curve-based cryptography + +## Mathematical Foundations + +### Groups in Cryptography + +A **group** is a set G with a binary operation (often denoted · or +) satisfying: + +1. **Closure**: For all a, b ∈ G, the result a · b is also in G +2. **Associativity**: (a · b) · c = a · (b · c) +3. **Identity**: There exists e ∈ G such that e · a = a · e = a +4. **Inverse**: For each a ∈ G, there exists a⁻¹ such that a · a⁻¹ = e + +A **cyclic group** is generated by repeatedly applying the operation to a single element (the generator). The **order** of a group is the number of elements. + +**Why groups matter in cryptography**: The discrete logarithm problem—given g and gⁿ, find n—is computationally hard in certain groups, forming the security basis for ECC. + +### Modular Arithmetic + +Modular arithmetic constrains calculations to a finite range [0, p-1] for some modulus p: + +``` +a ≡ b (mod p) means p divides (a - b) + +Operations: +- Addition: (a + b) mod p +- Subtraction: (a - b + p) mod p +- Multiplication: (a × b) mod p +- Inverse: a⁻¹ where (a × a⁻¹) ≡ 1 (mod p) +``` + +**Computing modular inverse**: +- **Fermat's Little Theorem**: If p is prime, a⁻¹ ≡ a^(p-2) (mod p) +- **Extended Euclidean Algorithm**: More efficient for general cases +- **SafeGCD Algorithm**: Constant-time, used in libsecp256k1 + +### Finite Fields (Galois Fields) + +A **finite field** GF(p) or 𝔽ₚ is a field with a finite number of elements where: +- p must be prime (or a prime power for extension fields) +- All arithmetic operations are defined and produce elements within the field +- Every non-zero element has a multiplicative inverse + +For cryptographic curves like secp256k1, the field is 𝔽ₚ where p is a 256-bit prime. + +**Key property**: The non-zero elements of a finite field form a cyclic group under multiplication. + +## Elliptic Curves + +### The Curve Equation + +An elliptic curve over a finite field 𝔽ₚ is defined by the Weierstrass equation: + +``` +y² = x³ + ax + b (mod p) +``` + +The curve must satisfy the non-singularity condition: 4a³ + 27b² ≠ 0 + +### Points on the Curve + +A point P = (x, y) is on the curve if it satisfies the equation. The set of all points, plus a special "point at infinity" O (the identity element), forms an abelian group. + +### Point Operations + +**Point Addition (P + Q where P ≠ Q)**: +``` +λ = (y₂ - y₁) / (x₂ - x₁) (mod p) +x₃ = λ² - x₁ - x₂ (mod p) +y₃ = λ(x₁ - x₃) - y₁ (mod p) +``` + +**Point Doubling (P + P = 2P)**: +``` +λ = (3x₁² + a) / (2y₁) (mod p) +x₃ = λ² - 2x₁ (mod p) +y₃ = λ(x₁ - x₃) - y₁ (mod p) +``` + +**Point at Infinity**: Acts as the identity element; P + O = P for all P. + +**Point Negation**: -P = (x, -y) = (x, p - y) + +## The secp256k1 Curve + +### Parameters + +secp256k1 is defined by SECG (Standards for Efficient Cryptography Group): + +``` +Curve equation: y² = x³ + 7 (a = 0, b = 7) + +Prime modulus p: + 0xFFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE FFFFFC2F + = 2²⁵⁶ - 2³² - 977 + +Group order n: + 0xFFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE BAAEDCE6 AF48A03B BFD25E8C D0364141 + +Generator point G: + Gx = 0x79BE667E F9DCBBAC 55A06295 CE870B07 029BFCDB 2DCE28D9 59F2815B 16F81798 + Gy = 0x483ADA77 26A3C465 5DA4FBFC 0E1108A8 FD17B448 A6855419 9C47D08F FB10D4B8 + +Cofactor h = 1 +``` + +### Why secp256k1? + +1. **Koblitz curve**: a = 0 enables faster computation (no ax term) +2. **Special prime**: p = 2²⁵⁶ - 2³² - 977 allows efficient modular reduction +3. **Deterministic construction**: Not randomly generated, reducing backdoor concerns +4. **~30% faster** than random curves when fully optimized + +### Efficient Modular Reduction + +The special form of p enables fast reduction without general division: + +``` +For p = 2²⁵⁶ - 2³² - 977: +To reduce a 512-bit number c = c_high × 2²⁵⁶ + c_low: + c ≡ c_low + c_high × 2³² + c_high × 977 (mod p) +``` + +## Point Multiplication Algorithms + +Scalar multiplication kP (computing P + P + ... + P, k times) is the core operation. + +### Double-and-Add (Binary Method) + +``` +Input: k (scalar), P (point) +Output: kP + +R = O (point at infinity) +for i from bit_length(k)-1 down to 0: + R = 2R # Point doubling + if bit i of k is 1: + R = R + P # Point addition +return R +``` + +**Complexity**: O(log k) point operations +**Vulnerability**: Timing side-channels (different branches for 0/1 bits) + +### Montgomery Ladder + +Constant-time algorithm that performs the same operations regardless of bit values: + +``` +Input: k (scalar), P (point) +Output: kP + +R0 = O +R1 = P +for i from bit_length(k)-1 down to 0: + if bit i of k is 0: + R1 = R0 + R1 + R0 = 2R0 + else: + R0 = R0 + R1 + R1 = 2R1 +return R0 +``` + +**Advantage**: Resistant to simple power analysis and timing attacks. + +### Window Methods (w-NAF) + +Precompute small multiples of P, then process w bits at a time: + +``` +w-NAF representation reduces additions by ~1/3 compared to binary +Precomputation table: [P, 3P, 5P, 7P, ...] for w=4 +``` + +### Endomorphism Optimization (GLV Method) + +secp256k1 has an efficiently computable endomorphism φ where: +``` +φ(x, y) = (βx, y) where β³ ≡ 1 (mod p) +φ(P) = λP where λ³ ≡ 1 (mod n) +``` + +This allows splitting scalar k into k₁ + k₂λ with smaller k₁, k₂, reducing operations by ~33-50%. + +### Multi-Scalar Multiplication (Strauss-Shamir) + +For computing k₁P₁ + k₂P₂ (common in signature verification): + +``` +Process both scalars simultaneously, combining operations +Reduces work compared to separate multiplications +``` + +## Coordinate Systems + +### Affine Coordinates + +Standard (x, y) representation. Requires modular inversion for each operation. + +### Projective Coordinates + +Represent (X:Y:Z) where x = X/Z, y = Y/Z: +- Avoids inversions during intermediate computations +- Only one inversion at the end to convert back to affine + +### Jacobian Coordinates + +Represent (X:Y:Z) where x = X/Z², y = Y/Z³: +- Fastest for point doubling +- Used extensively in libsecp256k1 + +### López-Dahab Coordinates + +For curves over GF(2ⁿ), optimized for binary field arithmetic. + +## Signature Schemes + +### ECDSA (Elliptic Curve Digital Signature Algorithm) + +**Key Generation**: +``` +Private key: d (random integer in [1, n-1]) +Public key: Q = dG +``` + +**Signing message m**: +``` +1. Hash: e = H(m) truncated to curve order bit length +2. Random: k ∈ [1, n-1] +3. Compute: (x, y) = kG +4. Calculate: r = x mod n (if r = 0, restart with new k) +5. Calculate: s = k⁻¹(e + rd) mod n (if s = 0, restart) +6. Signature: (r, s) +``` + +**Verification of signature (r, s) on message m**: +``` +1. Check: r, s ∈ [1, n-1] +2. Hash: e = H(m) +3. Compute: w = s⁻¹ mod n +4. Compute: u₁ = ew mod n, u₂ = rw mod n +5. Compute: (x, y) = u₁G + u₂Q +6. Valid if: r ≡ x (mod n) +``` + +**Security considerations**: +- k MUST be unique per signature (reuse leaks private key) +- Use RFC 6979 for deterministic k derivation + +### Schnorr Signatures (BIP-340) + +Simpler, more efficient, with provable security. + +**Signing message m**: +``` +1. Random: k ∈ [1, n-1] +2. Compute: R = kG +3. Challenge: e = H(R || Q || m) +4. Response: s = k + ed mod n +5. Signature: (R, s) or (r_x, s) where r_x is x-coordinate of R +``` + +**Verification**: +``` +1. Compute: e = H(R || Q || m) +2. Check: sG = R + eQ +``` + +**Advantages over ECDSA**: +- Linear: enables signature aggregation (MuSig) +- Simpler verification (no modular inverse) +- Batch verification support +- Provably secure in Random Oracle Model + +## Implementation Considerations + +### Constant-Time Operations + +To prevent timing attacks: +- Avoid branches dependent on secret data +- Use constant-time comparison functions +- Mask operations to hide data-dependent timing + +```go +// BAD: Timing leak +if secretBit == 1 { + doOperation() +} + +// GOOD: Constant-time conditional +result = conditionalSelect(secretBit, value1, value0) +``` + +### Memory Safety + +- Zeroize sensitive data after use +- Avoid leaving secrets in registers or cache +- Use secure memory allocation when available + +### Side-Channel Protections + +- **Timing attacks**: Use constant-time algorithms +- **Power analysis**: Montgomery ladder, point blinding +- **Cache attacks**: Avoid table lookups indexed by secrets + +### Random Number Generation + +- Use cryptographically secure RNG for k in ECDSA +- Consider deterministic k (RFC 6979) for reproducibility +- Validate output is in valid range [1, n-1] + +## libsecp256k1 Optimizations + +The Bitcoin Core library includes: + +1. **Field arithmetic**: 5×52-bit limbs for 64-bit platforms +2. **Scalar arithmetic**: 4×64-bit representation +3. **Endomorphism**: GLV decomposition enabled by default +4. **Batch inversion**: Amortizes expensive inversions +5. **SafeGCD**: Constant-time modular inverse +6. **Precomputed tables**: For generator point multiplications + +## Security Properties + +### Discrete Logarithm Problem (DLP) + +Given P and Q = kP, finding k is computationally infeasible. + +**Best known attacks**: +- Generic: Baby-step Giant-step, Pollard's rho: O(√n) operations +- For secp256k1: ~2¹²⁸ operations (128-bit security) + +### Curve Security Criteria + +- Large prime order subgroup +- Cofactor 1 (no small subgroup attacks) +- Resistant to MOV attack (embedding degree) +- Not anomalous (n ≠ p) + +## Common Pitfalls + +1. **k reuse in ECDSA**: Immediately leaks private key +2. **Weak random k**: Partially leaks key over multiple signatures +3. **Invalid curve points**: Validate points are on curve +4. **Small subgroup attacks**: Check point order (cofactor = 1 helps) +5. **Timing leaks**: Non-constant-time scalar multiplication + +## References + +For detailed implementations, see: +- `references/secp256k1-parameters.md` - Full curve parameters +- `references/algorithms.md` - Detailed algorithm pseudocode +- `references/security.md` - Security analysis and attack vectors diff --git a/.claude/skills/elliptic-curves/references/algorithms.md b/.claude/skills/elliptic-curves/references/algorithms.md new file mode 100644 index 00000000..63ec1dd2 --- /dev/null +++ b/.claude/skills/elliptic-curves/references/algorithms.md @@ -0,0 +1,513 @@ +# Elliptic Curve Algorithms + +Detailed pseudocode for core elliptic curve operations. + +## Field Arithmetic + +### Modular Addition + +``` +function mod_add(a, b, p): + result = a + b + if result >= p: + result = result - p + return result +``` + +### Modular Subtraction + +``` +function mod_sub(a, b, p): + if a >= b: + return a - b + else: + return p - b + a +``` + +### Modular Multiplication + +For general case: +``` +function mod_mul(a, b, p): + return (a * b) mod p +``` + +For secp256k1 optimized (Barrett reduction): +``` +function mod_mul_secp256k1(a, b): + # Compute full 512-bit product + product = a * b + + # Split into high and low 256-bit parts + low = product & ((1 << 256) - 1) + high = product >> 256 + + # Reduce: result ≡ low + high * (2³² + 977) (mod p) + result = low + high * (1 << 32) + high * 977 + + # May need additional reduction + while result >= p: + result = result - p + + return result +``` + +### Modular Inverse + +**Extended Euclidean Algorithm**: +``` +function mod_inverse(a, p): + if a == 0: + error "No inverse exists for 0" + + old_r, r = p, a + old_s, s = 0, 1 + + while r != 0: + quotient = old_r / r + old_r, r = r, old_r - quotient * r + old_s, s = s, old_s - quotient * s + + if old_r != 1: + error "No inverse exists" + + if old_s < 0: + old_s = old_s + p + + return old_s +``` + +**Fermat's Little Theorem** (for prime p): +``` +function mod_inverse_fermat(a, p): + return mod_exp(a, p - 2, p) +``` + +### Modular Exponentiation (Square-and-Multiply) + +``` +function mod_exp(base, exp, p): + result = 1 + base = base mod p + + while exp > 0: + if exp & 1: # exp is odd + result = (result * base) mod p + exp = exp >> 1 + base = (base * base) mod p + + return result +``` + +### Modular Square Root (Tonelli-Shanks) + +For secp256k1 where p ≡ 3 (mod 4): +``` +function mod_sqrt(a, p): + # For p ≡ 3 (mod 4), sqrt(a) = a^((p+1)/4) + return mod_exp(a, (p + 1) / 4, p) +``` + +## Point Operations + +### Point Validation + +``` +function is_on_curve(P, a, b, p): + if P is infinity: + return true + + x, y = P + left = (y * y) mod p + right = (x * x * x + a * x + b) mod p + + return left == right +``` + +### Point Addition (Affine Coordinates) + +``` +function point_add(P, Q, a, p): + if P is infinity: + return Q + if Q is infinity: + return P + + x1, y1 = P + x2, y2 = Q + + if x1 == x2: + if y1 == mod_neg(y2, p): # P = -Q + return infinity + else: # P == Q + return point_double(P, a, p) + + # λ = (y2 - y1) / (x2 - x1) + numerator = mod_sub(y2, y1, p) + denominator = mod_sub(x2, x1, p) + λ = mod_mul(numerator, mod_inverse(denominator, p), p) + + # x3 = λ² - x1 - x2 + x3 = mod_sub(mod_sub(mod_mul(λ, λ, p), x1, p), x2, p) + + # y3 = λ(x1 - x3) - y1 + y3 = mod_sub(mod_mul(λ, mod_sub(x1, x3, p), p), y1, p) + + return (x3, y3) +``` + +### Point Doubling (Affine Coordinates) + +``` +function point_double(P, a, p): + if P is infinity: + return infinity + + x, y = P + + if y == 0: + return infinity + + # λ = (3x² + a) / (2y) + numerator = mod_add(mod_mul(3, mod_mul(x, x, p), p), a, p) + denominator = mod_mul(2, y, p) + λ = mod_mul(numerator, mod_inverse(denominator, p), p) + + # x3 = λ² - 2x + x3 = mod_sub(mod_mul(λ, λ, p), mod_mul(2, x, p), p) + + # y3 = λ(x - x3) - y + y3 = mod_sub(mod_mul(λ, mod_sub(x, x3, p), p), y, p) + + return (x3, y3) +``` + +### Point Negation + +``` +function point_negate(P, p): + if P is infinity: + return infinity + + x, y = P + return (x, p - y) +``` + +## Scalar Multiplication + +### Double-and-Add (Left-to-Right) + +``` +function scalar_mult_double_add(k, P, a, p): + if k == 0 or P is infinity: + return infinity + + if k < 0: + k = -k + P = point_negate(P, p) + + R = infinity + bits = binary_representation(k) # MSB first + + for bit in bits: + R = point_double(R, a, p) + if bit == 1: + R = point_add(R, P, a, p) + + return R +``` + +### Montgomery Ladder (Constant-Time) + +``` +function scalar_mult_montgomery(k, P, a, p): + R0 = infinity + R1 = P + + bits = binary_representation(k) # MSB first + + for bit in bits: + if bit == 0: + R1 = point_add(R0, R1, a, p) + R0 = point_double(R0, a, p) + else: + R0 = point_add(R0, R1, a, p) + R1 = point_double(R1, a, p) + + return R0 +``` + +### w-NAF Scalar Multiplication + +``` +function compute_wNAF(k, w): + # Convert scalar to width-w Non-Adjacent Form + naf = [] + + while k > 0: + if k & 1: # k is odd + # Get w-bit window + digit = k mod (1 << w) + if digit >= (1 << (w-1)): + digit = digit - (1 << w) + naf.append(digit) + k = k - digit + else: + naf.append(0) + k = k >> 1 + + return naf + +function scalar_mult_wNAF(k, P, w, a, p): + # Precompute odd multiples: [P, 3P, 5P, ..., (2^(w-1)-1)P] + precomp = [P] + P2 = point_double(P, a, p) + for i in range(1, 1 << (w-1)): + precomp.append(point_add(precomp[-1], P2, a, p)) + + # Convert k to w-NAF + naf = compute_wNAF(k, w) + + # Compute scalar multiplication + R = infinity + for i in range(len(naf) - 1, -1, -1): + R = point_double(R, a, p) + digit = naf[i] + if digit > 0: + R = point_add(R, precomp[(digit - 1) / 2], a, p) + elif digit < 0: + R = point_add(R, point_negate(precomp[(-digit - 1) / 2], p), a, p) + + return R +``` + +### Shamir's Trick (Multi-Scalar) + +For computing k₁P + k₂Q efficiently: + +``` +function multi_scalar_mult(k1, P, k2, Q, a, p): + # Precompute P + Q + PQ = point_add(P, Q, a, p) + + # Get binary representations (same length, padded) + bits1 = binary_representation(k1) + bits2 = binary_representation(k2) + max_len = max(len(bits1), len(bits2)) + bits1 = pad_left(bits1, max_len) + bits2 = pad_left(bits2, max_len) + + R = infinity + + for i in range(max_len): + R = point_double(R, a, p) + + b1, b2 = bits1[i], bits2[i] + + if b1 == 1 and b2 == 1: + R = point_add(R, PQ, a, p) + elif b1 == 1: + R = point_add(R, P, a, p) + elif b2 == 1: + R = point_add(R, Q, a, p) + + return R +``` + +## Jacobian Coordinates + +More efficient for repeated operations. + +### Conversion + +``` +# Affine to Jacobian +function affine_to_jacobian(P): + if P is infinity: + return (1, 1, 0) # Jacobian infinity + x, y = P + return (x, y, 1) + +# Jacobian to Affine +function jacobian_to_affine(P, p): + X, Y, Z = P + if Z == 0: + return infinity + + Z_inv = mod_inverse(Z, p) + Z_inv2 = mod_mul(Z_inv, Z_inv, p) + Z_inv3 = mod_mul(Z_inv2, Z_inv, p) + + x = mod_mul(X, Z_inv2, p) + y = mod_mul(Y, Z_inv3, p) + + return (x, y) +``` + +### Point Doubling (Jacobian) + +For curve y² = x³ + 7 (a = 0): + +``` +function jacobian_double(P, p): + X, Y, Z = P + + if Y == 0: + return (1, 1, 0) # infinity + + # For a = 0: M = 3*X² + S = mod_mul(4, mod_mul(X, mod_mul(Y, Y, p), p), p) + M = mod_mul(3, mod_mul(X, X, p), p) + + X3 = mod_sub(mod_mul(M, M, p), mod_mul(2, S, p), p) + Y3 = mod_sub(mod_mul(M, mod_sub(S, X3, p), p), + mod_mul(8, mod_mul(Y, Y, mod_mul(Y, Y, p), p), p), p) + Z3 = mod_mul(2, mod_mul(Y, Z, p), p) + + return (X3, Y3, Z3) +``` + +### Point Addition (Jacobian + Affine) + +Mixed addition is faster when one point is in affine: + +``` +function jacobian_add_affine(P, Q, p): + # P in Jacobian (X1, Y1, Z1), Q in affine (x2, y2) + X1, Y1, Z1 = P + x2, y2 = Q + + if Z1 == 0: + return affine_to_jacobian(Q) + + Z1Z1 = mod_mul(Z1, Z1, p) + U2 = mod_mul(x2, Z1Z1, p) + S2 = mod_mul(y2, mod_mul(Z1, Z1Z1, p), p) + + H = mod_sub(U2, X1, p) + HH = mod_mul(H, H, p) + I = mod_mul(4, HH, p) + J = mod_mul(H, I, p) + r = mod_mul(2, mod_sub(S2, Y1, p), p) + V = mod_mul(X1, I, p) + + X3 = mod_sub(mod_sub(mod_mul(r, r, p), J, p), mod_mul(2, V, p), p) + Y3 = mod_sub(mod_mul(r, mod_sub(V, X3, p), p), mod_mul(2, mod_mul(Y1, J, p), p), p) + Z3 = mod_mul(mod_sub(mod_mul(mod_add(Z1, H, p), mod_add(Z1, H, p), p), + mod_add(Z1Z1, HH, p), p), 1, p) + + return (X3, Y3, Z3) +``` + +## GLV Endomorphism (secp256k1) + +### Scalar Decomposition + +``` +# Constants for secp256k1 +LAMBDA = 0x5363AD4CC05C30E0A5261C028812645A122E22EA20816678DF02967C1B23BD72 +BETA = 0x7AE96A2B657C07106E64479EAC3434E99CF0497512F58995C1396C28719501EE + +# Decomposition coefficients +A1 = 0x3086D221A7D46BCDE86C90E49284EB15 +B1 = 0x114CA50F7A8E2F3F657C1108D9D44CFD8 +A2 = 0xE4437ED6010E88286F547FA90ABFE4C3 +B2 = A1 + +function glv_decompose(k, n): + # Compute c1 = round(b2 * k / n) + # Compute c2 = round(-b1 * k / n) + c1 = (B2 * k + n // 2) // n + c2 = (-B1 * k + n // 2) // n + + # k1 = k - c1*A1 - c2*A2 + # k2 = -c1*B1 - c2*B2 + k1 = k - c1 * A1 - c2 * A2 + k2 = -c1 * B1 - c2 * B2 + + return (k1, k2) + +function glv_scalar_mult(k, P, p, n): + k1, k2 = glv_decompose(k, n) + + # Compute endomorphism: φ(P) = (β*x, y) + x, y = P + phi_P = (mod_mul(BETA, x, p), y) + + # Use Shamir's trick: k1*P + k2*φ(P) + return multi_scalar_mult(k1, P, k2, phi_P, 0, p) +``` + +## Batch Inversion + +Amortize expensive inversions over multiple points: + +``` +function batch_invert(values, p): + n = len(values) + if n == 0: + return [] + + # Compute cumulative products + products = [values[0]] + for i in range(1, n): + products.append(mod_mul(products[-1], values[i], p)) + + # Invert the final product + inv = mod_inverse(products[-1], p) + + # Compute individual inverses + inverses = [0] * n + for i in range(n - 1, 0, -1): + inverses[i] = mod_mul(inv, products[i - 1], p) + inv = mod_mul(inv, values[i], p) + inverses[0] = inv + + return inverses +``` + +## Key Generation + +``` +function generate_keypair(G, n, p): + # Generate random private key + d = random_integer(1, n - 1) + + # Compute public key + Q = scalar_mult(d, G) + + return (d, Q) +``` + +## Point Compression/Decompression + +``` +function compress_point(P, p): + if P is infinity: + return bytes([0x00]) + + x, y = P + prefix = 0x02 if (y % 2 == 0) else 0x03 + return bytes([prefix]) + x.to_bytes(32, 'big') + +function decompress_point(compressed, a, b, p): + prefix = compressed[0] + + if prefix == 0x00: + return infinity + + x = int.from_bytes(compressed[1:], 'big') + + # Compute y² = x³ + ax + b + y_squared = mod_add(mod_add(mod_mul(x, mod_mul(x, x, p), p), + mod_mul(a, x, p), p), b, p) + + # Compute y = sqrt(y²) + y = mod_sqrt(y_squared, p) + + # Select correct y based on prefix + if (prefix == 0x02) != (y % 2 == 0): + y = p - y + + return (x, y) +``` \ No newline at end of file diff --git a/.claude/skills/elliptic-curves/references/secp256k1-parameters.md b/.claude/skills/elliptic-curves/references/secp256k1-parameters.md new file mode 100644 index 00000000..a8ed0561 --- /dev/null +++ b/.claude/skills/elliptic-curves/references/secp256k1-parameters.md @@ -0,0 +1,194 @@ +# secp256k1 Complete Parameters + +## Curve Definition + +**Name**: secp256k1 (Standards for Efficient Cryptography, prime field, 256-bit, Koblitz curve #1) + +**Equation**: y² = x³ + 7 (mod p) + +This is the short Weierstrass form with coefficients a = 0, b = 7. + +## Field Parameters + +### Prime Modulus p + +``` +Decimal: +115792089237316195423570985008687907853269984665640564039457584007908834671663 + +Hexadecimal: +0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F + +Binary representation: +2²⁵⁶ - 2³² - 2⁹ - 2⁸ - 2⁷ - 2⁶ - 2⁴ - 1 += 2²⁵⁶ - 2³² - 977 +``` + +**Special form benefits**: +- Efficient modular reduction using: c mod p = c_low + c_high × (2³² + 977) +- Near-Mersenne prime enables fast arithmetic + +### Group Order n + +``` +Decimal: +115792089237316195423570985008687907852837564279074904382605163141518161494337 + +Hexadecimal: +0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +``` + +The number of points on the curve, including the point at infinity. + +### Cofactor h + +``` +h = 1 +``` + +Cofactor 1 means the group order n equals the curve order, simplifying security analysis and eliminating small subgroup attacks. + +## Generator Point G + +### Compressed Form + +``` +02 79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798 +``` + +The 02 prefix indicates the y-coordinate is even. + +### Uncompressed Form + +``` +04 79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798 + 483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8 +``` + +### Individual Coordinates + +**Gx**: +``` +Decimal: +55066263022277343669578718895168534326250603453777594175500187360389116729240 + +Hexadecimal: +0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798 +``` + +**Gy**: +``` +Decimal: +32670510020758816978083085130507043184471273380659243275938904335757337482424 + +Hexadecimal: +0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8 +``` + +## Endomorphism Parameters + +secp256k1 has an efficiently computable endomorphism φ: (x, y) → (βx, y). + +### β (Beta) + +``` +Hexadecimal: +0x7AE96A2B657C07106E64479EAC3434E99CF0497512F58995C1396C28719501EE + +Property: β³ ≡ 1 (mod p) +``` + +### λ (Lambda) + +``` +Hexadecimal: +0x5363AD4CC05C30E0A5261C028812645A122E22EA20816678DF02967C1B23BD72 + +Property: λ³ ≡ 1 (mod n) +Relationship: φ(P) = λP for all points P +``` + +### GLV Decomposition Constants + +For splitting scalar k into k₁ + k₂λ: + +``` +a₁ = 0x3086D221A7D46BCDE86C90E49284EB15 +b₁ = -0xE4437ED6010E88286F547FA90ABFE4C3 +a₂ = 0x114CA50F7A8E2F3F657C1108D9D44CFD8 +b₂ = a₁ +``` + +## Derived Constants + +### Field Characteristics + +``` +(p + 1) / 4 = 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFBFFFFF0C +Used for computing modular square roots via Tonelli-Shanks shortcut +``` + +### Order Characteristics + +``` +(n - 1) / 2 = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0 +Used in low-S normalization for ECDSA signatures +``` + +## Validation Formulas + +### Point on Curve Check + +For point (x, y), verify: +``` +y² ≡ x³ + 7 (mod p) +``` + +### Generator Verification + +Verify G is on curve: +``` +Gy² mod p = 0x9C47D08FFB10D4B8 ... (truncated for display) +Gx³ + 7 mod p = same value +``` + +### Order Verification + +Verify nG = O (point at infinity): +``` +Computing n × G should yield the identity element +``` + +## Bit Lengths + +| Parameter | Bits | Bytes | +|-----------|------|-------| +| p (prime) | 256 | 32 | +| n (order) | 256 | 32 | +| Private key | 256 | 32 | +| Public key (compressed) | 257 | 33 | +| Public key (uncompressed) | 513 | 65 | +| ECDSA signature | 512 | 64 | +| Schnorr signature | 512 | 64 | + +## Security Level + +- **Equivalent symmetric key strength**: 128 bits +- **Best known attack complexity**: ~2¹²⁸ operations (Pollard's rho) +- **Safe until**: Quantum computers with ~1500+ logical qubits + +## ASN.1 OID + +``` +1.3.132.0.10 +iso(1) identified-organization(3) certicom(132) curve(0) secp256k1(10) +``` + +## Comparison with Other Curves + +| Curve | Field Size | Security | Speed | Use Case | +|-------|------------|----------|-------|----------| +| secp256k1 | 256-bit | 128-bit | Fast (Koblitz) | Bitcoin, Nostr | +| secp256r1 (P-256) | 256-bit | 128-bit | Moderate | TLS, general | +| Curve25519 | 255-bit | ~128-bit | Very fast | Modern crypto | +| secp384r1 (P-384) | 384-bit | 192-bit | Slower | High security | diff --git a/.claude/skills/elliptic-curves/references/security.md b/.claude/skills/elliptic-curves/references/security.md new file mode 100644 index 00000000..8c241bfd --- /dev/null +++ b/.claude/skills/elliptic-curves/references/security.md @@ -0,0 +1,291 @@ +# Elliptic Curve Security Analysis + +Security properties, attack vectors, and mitigations for elliptic curve cryptography. + +## The Discrete Logarithm Problem (ECDLP) + +### Definition + +Given points P and Q = kP on an elliptic curve, find the scalar k. + +**Security assumption**: For properly chosen curves, this problem is computationally infeasible. + +### Best Known Attacks + +#### Generic Attacks (Work on Any Group) + +| Attack | Complexity | Notes | +|--------|------------|-------| +| Baby-step Giant-step | O(√n) space and time | Requires √n storage | +| Pollard's rho | O(√n) time, O(1) space | Practical for large groups | +| Pollard's lambda | O(√n) | When k is in known range | +| Pohlig-Hellman | O(√p) where p is largest prime factor | Exploits factorization of n | + +For secp256k1 (n ≈ 2²⁵⁶): +- Generic attack complexity: ~2¹²⁸ operations +- Equivalent to 128-bit symmetric security + +#### Curve-Specific Attacks + +| Attack | Applicable When | Mitigation | +|--------|-----------------|------------| +| MOV/FR reduction | Low embedding degree | Use curves with high embedding degree | +| Anomalous curve attack | n = p | Ensure n ≠ p | +| GHS attack | Extension field curves | Use prime field curves | + +**secp256k1 is immune to all known curve-specific attacks**. + +## Side-Channel Attacks + +### Timing Attacks + +**Vulnerability**: Execution time varies based on secret data. + +**Examples**: +- Conditional branches on secret bits +- Early exit conditions +- Variable-time modular operations + +**Mitigations**: +- Constant-time algorithms (Montgomery ladder) +- Fixed execution paths +- Dummy operations to equalize timing + +### Power Analysis + +**Simple Power Analysis (SPA)**: Single trace reveals operations. +- Double-and-add visible as different power signatures +- Mitigation: Montgomery ladder (uniform operations) + +**Differential Power Analysis (DPA)**: Statistical analysis of many traces. +- Mitigation: Point blinding, scalar blinding + +### Cache Attacks + +**FLUSH+RELOAD Attack**: +``` +1. Attacker flushes cache line containing lookup table +2. Victim performs table lookup based on secret +3. Attacker measures reload time to determine which entry was accessed +``` + +**Mitigations**: +- Avoid secret-dependent table lookups +- Use constant-time table access patterns +- Scatter tables to prevent cache line sharing + +### Electromagnetic (EM) Attacks + +Similar to power analysis but captures electromagnetic emissions. + +**Mitigations**: +- Shielding +- Same algorithmic protections as power analysis + +## Implementation Vulnerabilities + +### k-Reuse in ECDSA + +**The Sony PS3 Hack (2010)**: + +If the same k is used for two signatures (r₁, s₁) and (r₂, s₂) on messages m₁ and m₂: + +``` +s₁ = k⁻¹(e₁ + rd) mod n +s₂ = k⁻¹(e₂ + rd) mod n + +Since k is the same: +s₁ - s₂ = k⁻¹(e₁ - e₂) mod n +k = (e₁ - e₂)(s₁ - s₂)⁻¹ mod n + +Once k is known: +d = (s₁k - e₁)r⁻¹ mod n +``` + +**Mitigation**: Use deterministic k (RFC 6979). + +### Weak Random k + +Even with unique k values, if the RNG is biased: +- Lattice-based attacks can recover private key +- Only ~1% bias in k can be exploitable with enough signatures + +**Mitigations**: +- Use cryptographically secure RNG +- Use deterministic k (RFC 6979) +- Verify k is in valid range [1, n-1] + +### Invalid Curve Attacks + +**Attack**: Attacker provides point not on the curve. +- Point may be on a weaker curve +- Operations may leak information + +**Mitigation**: Always validate points are on curve: +``` +Verify: y² ≡ x³ + ax + b (mod p) +``` + +### Small Subgroup Attacks + +**Attack**: If cofactor h > 1, points of small order exist. +- Attacker sends point of small order +- Response reveals private key mod (small order) + +**Mitigation**: +- Use curves with cofactor 1 (secp256k1 has h = 1) +- Multiply received points by cofactor +- Validate point order + +### Fault Attacks + +**Attack**: Induce computational errors (voltage glitches, radiation). +- Corrupted intermediate values may leak information +- Differential fault analysis can recover keys + +**Mitigations**: +- Redundant computations with comparison +- Verify final results +- Hardware protections + +## Signature Malleability + +### ECDSA Malleability + +Given valid signature (r, s), signature (r, n - s) is also valid for the same message. + +**Impact**: Transaction ID malleability (historical Bitcoin issue) + +**Mitigation**: Enforce low-S normalization: +``` +if s > n/2: + s = n - s +``` + +### Schnorr Non-Malleability + +BIP-340 Schnorr signatures are non-malleable by design: +- Use x-only public keys +- Deterministic nonce derivation + +## Quantum Threats + +### Shor's Algorithm + +**Threat**: Polynomial-time discrete log on quantum computers. +- Requires ~1500-2000 logical qubits for secp256k1 +- Current quantum computers: <100 noisy qubits + +**Timeline**: Estimated 10-20+ years for cryptographically relevant quantum computers. + +### Migration Strategy + +1. **Monitor**: Track quantum computing progress +2. **Prepare**: Develop post-quantum alternatives +3. **Hybrid**: Use classical + post-quantum in transition +4. **Migrate**: Full transition when necessary + +### Post-Quantum Alternatives + +- Lattice-based signatures (CRYSTALS-Dilithium) +- Hash-based signatures (SPHINCS+) +- Code-based cryptography + +## Best Practices + +### Key Generation + +``` +DO: +- Use cryptographically secure RNG +- Validate private key is in [1, n-1] +- Verify public key is on curve +- Verify public key is not point at infinity + +DON'T: +- Use predictable seeds +- Use truncated random values +- Skip validation +``` + +### Signature Generation + +``` +DO: +- Use RFC 6979 for deterministic k +- Validate all inputs +- Use constant-time operations +- Clear sensitive memory after use + +DON'T: +- Reuse k values +- Use weak/biased RNG +- Skip low-S normalization (ECDSA) +``` + +### Signature Verification + +``` +DO: +- Validate r, s are in [1, n-1] +- Validate public key is on curve +- Validate public key is not infinity +- Use batch verification when possible + +DON'T: +- Skip any validation steps +- Accept malformed signatures +``` + +### Public Key Handling + +``` +DO: +- Validate received points are on curve +- Check point is not infinity +- Prefer compressed format for storage + +DON'T: +- Accept unvalidated points +- Skip curve membership check +``` + +## Security Checklist + +### Implementation Review + +- [ ] All scalar multiplications are constant-time +- [ ] No secret-dependent branches +- [ ] No secret-indexed table lookups +- [ ] Memory is zeroized after use +- [ ] Random k uses CSPRNG or RFC 6979 +- [ ] All received points are validated +- [ ] Private keys are in valid range +- [ ] Signatures use low-S normalization + +### Operational Security + +- [ ] Private keys stored securely (HSM, secure enclave) +- [ ] Key derivation uses proper KDF +- [ ] Backups are encrypted +- [ ] Key rotation policy exists +- [ ] Audit logging enabled +- [ ] Incident response plan exists + +## Security Levels Comparison + +| Curve | Bits | Symmetric Equivalent | RSA Equivalent | +|-------|------|---------------------|----------------| +| secp192r1 | 192 | 96 | 1536 | +| secp224r1 | 224 | 112 | 2048 | +| secp256k1 | 256 | 128 | 3072 | +| secp384r1 | 384 | 192 | 7680 | +| secp521r1 | 521 | 256 | 15360 | + +## References + +- NIST SP 800-57: Recommendation for Key Management +- SEC 1: Elliptic Curve Cryptography +- RFC 6979: Deterministic Usage of DSA and ECDSA +- BIP-340: Schnorr Signatures for secp256k1 +- SafeCurves: Choosing Safe Curves for Elliptic-Curve Cryptography diff --git a/.claude/skills/go-memory-optimization/SKILL.md b/.claude/skills/go-memory-optimization/SKILL.md new file mode 100644 index 00000000..f5ed5810 --- /dev/null +++ b/.claude/skills/go-memory-optimization/SKILL.md @@ -0,0 +1,478 @@ +--- +name: go-memory-optimization +description: This skill should be used when optimizing Go code for memory efficiency, reducing GC pressure, implementing object pooling, analyzing escape behavior, choosing between fixed-size arrays and slices, designing worker pools, or profiling memory allocations. Provides comprehensive knowledge of Go's memory model, stack vs heap allocation, sync.Pool patterns, goroutine reuse, and GC tuning. +--- + +# Go Memory Optimization + +## Overview + +This skill provides guidance on optimizing Go programs for memory efficiency and reduced garbage collection overhead. Topics include stack allocation semantics, fixed-size types, escape analysis, object pooling, goroutine management, and GC tuning. + +## Core Principles + +### The Allocation Hierarchy + +Prefer allocations in this order (fastest to slowest): + +1. **Stack allocation** - Zero GC cost, automatic cleanup on function return +2. **Pooled objects** - Amortized allocation cost via sync.Pool +3. **Pre-allocated buffers** - Single allocation, reused across operations +4. **Heap allocation** - GC-managed, use when lifetime exceeds function scope + +### When Optimization Matters + +Focus memory optimization efforts on: +- Hot paths executed thousands/millions of times per second +- Large objects (>32KB) that stress the GC +- Long-running services where GC pauses affect latency +- Memory-constrained environments + +Avoid premature optimization. Profile first with `go tool pprof` to identify actual bottlenecks. + +## Fixed-Size Types vs Slices + +### Stack Allocation with Arrays + +Arrays with known compile-time size can be stack-allocated, avoiding heap entirely: + +```go +// HEAP: slice header + backing array escape to heap +func processSlice() []byte { + data := make([]byte, 32) + // ... use data + return data // escapes +} + +// STACK: fixed array stays on stack if doesn't escape +func processArray() { + var data [32]byte // stack-allocated + // ... use data +} // automatically cleaned up +``` + +### Fixed-Size Binary Types Pattern + +Define types with explicit sizes for protocol fields, cryptographic values, and identifiers: + +```go +// Binary types enforce length and enable stack allocation +type EventID [32]byte // SHA256 hash +type Pubkey [32]byte // Schnorr public key +type Signature [64]byte // Schnorr signature + +// Methods operate on value receivers when size permits +func (id EventID) Hex() string { + return hex.EncodeToString(id[:]) +} + +func (id EventID) IsZero() bool { + return id == EventID{} // efficient zero-value comparison +} +``` + +### Size Thresholds + +| Size | Recommendation | +|------|----------------| +| ≤64 bytes | Pass by value, stack-friendly | +| 65-128 bytes | Consider context; value for read-only, pointer for mutation | +| >128 bytes | Pass by pointer to avoid copy overhead | + +### Array to Slice Conversion + +Convert fixed arrays to slices only at API boundaries: + +```go +type Hash [32]byte + +func (h Hash) Bytes() []byte { + return h[:] // creates slice header, array stays on stack if h does +} + +// Prefer methods that accept arrays directly +func VerifySignature(pubkey Pubkey, msg []byte, sig Signature) bool { + // pubkey and sig are stack-allocated in caller +} +``` + +## Escape Analysis + +### Understanding Escape + +Variables "escape" to the heap when the compiler cannot prove their lifetime is bounded by the stack frame. Check escape behavior with: + +```bash +go build -gcflags="-m -m" ./... +``` + +### Common Escape Causes + +```go +// 1. Returning pointers to local variables +func escapes() *int { + x := 42 + return &x // x escapes +} + +// 2. Storing in interface{} +func escapes(x int) interface{} { + return x // x escapes (boxed) +} + +// 3. Closures capturing by reference +func escapes() func() int { + x := 42 + return func() int { return x } // x escapes +} + +// 4. Slice/map with unknown capacity +func escapes(n int) []byte { + return make([]byte, n) // escapes (size unknown at compile time) +} + +// 5. Sending pointers to channels +func escapes(ch chan *int) { + x := 42 + ch <- &x // x escapes +} +``` + +### Preventing Escape + +```go +// 1. Accept pointers, don't return them +func noEscape(result *[32]byte) { + // caller owns memory, function fills it + copy(result[:], computeHash()) +} + +// 2. Use fixed-size arrays +func noEscape() { + var buf [1024]byte // known size, stack-allocated + process(buf[:]) +} + +// 3. Preallocate with known capacity +func noEscape() { + buf := make([]byte, 0, 1024) // may stay on stack + // ... append up to 1024 bytes +} + +// 4. Avoid interface{} on hot paths +func noEscape(x int) int { + return x * 2 // no boxing +} +``` + +## sync.Pool Usage + +### Basic Pattern + +```go +var bufferPool = sync.Pool{ + New: func() interface{} { + return make([]byte, 0, 4096) + }, +} + +func processRequest(data []byte) { + buf := bufferPool.Get().([]byte) + buf = buf[:0] // reset length, keep capacity + defer bufferPool.Put(buf) + + // use buf... +} +``` + +### Typed Pool Wrapper + +```go +type BufferPool struct { + pool sync.Pool + size int +} + +func NewBufferPool(size int) *BufferPool { + return &BufferPool{ + pool: sync.Pool{ + New: func() interface{} { + b := make([]byte, size) + return &b + }, + }, + size: size, + } +} + +func (p *BufferPool) Get() *[]byte { + return p.pool.Get().(*[]byte) +} + +func (p *BufferPool) Put(b *[]byte) { + if b == nil || cap(*b) < p.size { + return // don't pool undersized buffers + } + *b = (*b)[:p.size] // reset to full size + p.pool.Put(b) +} +``` + +### Pool Anti-Patterns + +```go +// BAD: Pool of pointers to small values (overhead exceeds benefit) +var intPool = sync.Pool{New: func() interface{} { return new(int) }} + +// BAD: Not resetting state before Put +bufPool.Put(buf) // may contain sensitive data + +// BAD: Pooling objects with goroutine-local state +var connPool = sync.Pool{...} // connections are stateful + +// BAD: Assuming pooled objects persist (GC clears pools) +obj := pool.Get() +// ... long delay +pool.Put(obj) // obj may have been GC'd during delay +``` + +### When to Use sync.Pool + +| Use Case | Pool? | Reason | +|----------|-------|--------| +| Buffers in HTTP handlers | Yes | High allocation rate, short lifetime | +| Encoder/decoder state | Yes | Expensive to initialize | +| Small values (<64 bytes) | No | Pointer overhead exceeds benefit | +| Long-lived objects | No | Pools are for short-lived reuse | +| Objects with cleanup needs | No | Pool provides no finalization | + +## Goroutine Pooling + +### Worker Pool Pattern + +```go +type WorkerPool struct { + jobs chan func() + workers int + wg sync.WaitGroup +} + +func NewWorkerPool(workers, queueSize int) *WorkerPool { + p := &WorkerPool{ + jobs: make(chan func(), queueSize), + workers: workers, + } + p.wg.Add(workers) + for i := 0; i < workers; i++ { + go p.worker() + } + return p +} + +func (p *WorkerPool) worker() { + defer p.wg.Done() + for job := range p.jobs { + job() + } +} + +func (p *WorkerPool) Submit(job func()) { + p.jobs <- job +} + +func (p *WorkerPool) Shutdown() { + close(p.jobs) + p.wg.Wait() +} +``` + +### Bounded Concurrency with Semaphore + +```go +type Semaphore struct { + sem chan struct{} +} + +func NewSemaphore(n int) *Semaphore { + return &Semaphore{sem: make(chan struct{}, n)} +} + +func (s *Semaphore) Acquire() { s.sem <- struct{}{} } +func (s *Semaphore) Release() { <-s.sem } + +// Usage +sem := NewSemaphore(runtime.GOMAXPROCS(0)) +for _, item := range items { + sem.Acquire() + go func(it Item) { + defer sem.Release() + process(it) + }(item) +} +``` + +### Goroutine Reuse Benefits + +| Metric | Spawn per request | Worker pool | +|--------|-------------------|-------------| +| Goroutine creation | O(n) | O(workers) | +| Stack allocation | 2KB × n | 2KB × workers | +| Scheduler overhead | Higher | Lower | +| GC pressure | Higher | Lower | + +## Reducing GC Pressure + +### Allocation Reduction Strategies + +```go +// 1. Reuse buffers across iterations +buf := make([]byte, 0, 4096) +for _, item := range items { + buf = buf[:0] // reset without reallocation + buf = processItem(buf, item) +} + +// 2. Preallocate slices with known length +result := make([]Item, 0, len(input)) // avoid append reallocations +for _, in := range input { + result = append(result, transform(in)) +} + +// 3. Struct embedding instead of pointer fields +type Event struct { + ID [32]byte // embedded, not *[32]byte + Pubkey [32]byte // single allocation for entire struct + Signature [64]byte + Content string // only string data on heap +} + +// 4. String interning for repeated values +var kindStrings = map[int]string{ + 0: "set_metadata", + 1: "text_note", + // ... +} +``` + +### GC Tuning + +```go +import "runtime/debug" + +func init() { + // GOGC: target heap growth percentage (default 100) + // Lower = more frequent GC, less memory + // Higher = less frequent GC, more memory + debug.SetGCPercent(50) // GC when heap grows 50% + + // GOMEMLIMIT: soft memory limit (Go 1.19+) + // GC becomes more aggressive as limit approaches + debug.SetMemoryLimit(512 << 20) // 512MB limit +} +``` + +Environment variables: + +```bash +GOGC=50 # More aggressive GC +GOMEMLIMIT=512MiB # Soft memory limit +GODEBUG=gctrace=1 # GC trace output +``` + +### Arena Allocation (Go 1.20+, experimental) + +```go +//go:build goexperiment.arenas + +import "arena" + +func processLargeDataset(data []byte) Result { + a := arena.NewArena() + defer a.Free() // bulk free all allocations + + // All allocations from arena are freed together + items := arena.MakeSlice[Item](a, 0, 1000) + // ... process + + // Copy result out before Free + return copyResult(result) +} +``` + +## Memory Profiling + +### Heap Profile + +```go +import "runtime/pprof" + +func captureHeapProfile() { + f, _ := os.Create("heap.prof") + defer f.Close() + runtime.GC() // get accurate picture + pprof.WriteHeapProfile(f) +} +``` + +```bash +go tool pprof -http=:8080 heap.prof +go tool pprof -alloc_space heap.prof # total allocations +go tool pprof -inuse_space heap.prof # current usage +``` + +### Allocation Benchmarks + +```go +func BenchmarkAllocation(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + result := processData(input) + _ = result + } +} +``` + +Output interpretation: + +``` +BenchmarkAllocation-8 1000000 1234 ns/op 256 B/op 3 allocs/op + ↑ ↑ + bytes/op allocations/op +``` + +### Live Memory Monitoring + +```go +func printMemStats() { + var m runtime.MemStats + runtime.ReadMemStats(&m) + fmt.Printf("Alloc: %d MB\n", m.Alloc/1024/1024) + fmt.Printf("TotalAlloc: %d MB\n", m.TotalAlloc/1024/1024) + fmt.Printf("Sys: %d MB\n", m.Sys/1024/1024) + fmt.Printf("NumGC: %d\n", m.NumGC) + fmt.Printf("GCPause: %v\n", time.Duration(m.PauseNs[(m.NumGC+255)%256])) +} +``` + +## Common Patterns Reference + +For detailed code examples and patterns, see `references/patterns.md`: + +- Buffer pool implementations +- Zero-allocation JSON encoding +- Memory-efficient string building +- Slice capacity management +- Struct layout optimization + +## Checklist for Memory-Critical Code + +1. [ ] Profile before optimizing (`go tool pprof`) +2. [ ] Check escape analysis output (`-gcflags="-m"`) +3. [ ] Use fixed-size arrays for known-size data +4. [ ] Implement sync.Pool for frequently allocated objects +5. [ ] Preallocate slices with known capacity +6. [ ] Reuse buffers instead of allocating new ones +7. [ ] Consider struct field ordering for alignment +8. [ ] Benchmark with `-benchmem` flag +9. [ ] Set appropriate GOGC/GOMEMLIMIT for production +10. [ ] Monitor GC behavior with GODEBUG=gctrace=1 diff --git a/.claude/skills/go-memory-optimization/references/patterns.md b/.claude/skills/go-memory-optimization/references/patterns.md new file mode 100644 index 00000000..199704af --- /dev/null +++ b/.claude/skills/go-memory-optimization/references/patterns.md @@ -0,0 +1,594 @@ +# Go Memory Optimization Patterns + +Detailed code examples and patterns for memory-efficient Go programming. + +## Buffer Pool Implementations + +### Tiered Buffer Pool + +For workloads with varying buffer sizes: + +```go +type TieredPool struct { + small sync.Pool // 1KB + medium sync.Pool // 16KB + large sync.Pool // 256KB +} + +func NewTieredPool() *TieredPool { + return &TieredPool{ + small: sync.Pool{New: func() interface{} { return make([]byte, 1024) }}, + medium: sync.Pool{New: func() interface{} { return make([]byte, 16384) }}, + large: sync.Pool{New: func() interface{} { return make([]byte, 262144) }}, + } +} + +func (p *TieredPool) Get(size int) []byte { + switch { + case size <= 1024: + return p.small.Get().([]byte)[:size] + case size <= 16384: + return p.medium.Get().([]byte)[:size] + case size <= 262144: + return p.large.Get().([]byte)[:size] + default: + return make([]byte, size) // too large for pool + } +} + +func (p *TieredPool) Put(b []byte) { + switch cap(b) { + case 1024: + p.small.Put(b[:cap(b)]) + case 16384: + p.medium.Put(b[:cap(b)]) + case 262144: + p.large.Put(b[:cap(b)]) + } + // Non-standard sizes are not pooled +} +``` + +### bytes.Buffer Pool + +```go +var bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func GetBuffer() *bytes.Buffer { + return bufferPool.Get().(*bytes.Buffer) +} + +func PutBuffer(b *bytes.Buffer) { + b.Reset() + bufferPool.Put(b) +} + +// Usage +func processData(data []byte) string { + buf := GetBuffer() + defer PutBuffer(buf) + + buf.WriteString("prefix:") + buf.Write(data) + buf.WriteString(":suffix") + + return buf.String() // allocates new string +} +``` + +## Zero-Allocation JSON Encoding + +### Pre-allocated Encoder + +```go +type JSONEncoder struct { + buf []byte + scratch [64]byte // for number formatting +} + +func (e *JSONEncoder) Reset() { + e.buf = e.buf[:0] +} + +func (e *JSONEncoder) Bytes() []byte { + return e.buf +} + +func (e *JSONEncoder) WriteString(s string) { + e.buf = append(e.buf, '"') + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '"': + e.buf = append(e.buf, '\\', '"') + case '\\': + e.buf = append(e.buf, '\\', '\\') + case '\n': + e.buf = append(e.buf, '\\', 'n') + case '\r': + e.buf = append(e.buf, '\\', 'r') + case '\t': + e.buf = append(e.buf, '\\', 't') + default: + if c < 0x20 { + e.buf = append(e.buf, '\\', 'u', '0', '0', + hexDigits[c>>4], hexDigits[c&0xf]) + } else { + e.buf = append(e.buf, c) + } + } + } + e.buf = append(e.buf, '"') +} + +func (e *JSONEncoder) WriteInt(n int64) { + e.buf = strconv.AppendInt(e.buf, n, 10) +} + +func (e *JSONEncoder) WriteHex(b []byte) { + e.buf = append(e.buf, '"') + for _, v := range b { + e.buf = append(e.buf, hexDigits[v>>4], hexDigits[v&0xf]) + } + e.buf = append(e.buf, '"') +} + +var hexDigits = [16]byte{'0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'} +``` + +### Append-Based Encoding + +```go +// AppendJSON appends JSON representation to dst, returning extended slice +func (ev *Event) AppendJSON(dst []byte) []byte { + dst = append(dst, `{"id":"`...) + dst = appendHex(dst, ev.ID[:]) + dst = append(dst, `","pubkey":"`...) + dst = appendHex(dst, ev.Pubkey[:]) + dst = append(dst, `","created_at":`...) + dst = strconv.AppendInt(dst, ev.CreatedAt, 10) + dst = append(dst, `,"kind":`...) + dst = strconv.AppendInt(dst, int64(ev.Kind), 10) + dst = append(dst, `,"content":`...) + dst = appendJSONString(dst, ev.Content) + dst = append(dst, '}') + return dst +} + +// Usage with pre-allocated buffer +func encodeEvents(events []Event) []byte { + // Estimate size: ~500 bytes per event + buf := make([]byte, 0, len(events)*500) + buf = append(buf, '[') + for i, ev := range events { + if i > 0 { + buf = append(buf, ',') + } + buf = ev.AppendJSON(buf) + } + buf = append(buf, ']') + return buf +} +``` + +## Memory-Efficient String Building + +### strings.Builder with Preallocation + +```go +func buildQuery(parts []string) string { + // Calculate total length + total := len(parts) - 1 // for separators + for _, p := range parts { + total += len(p) + } + + var b strings.Builder + b.Grow(total) // single allocation + + for i, p := range parts { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(p) + } + return b.String() +} +``` + +### Avoiding String Concatenation + +```go +// BAD: O(n^2) allocations +func buildPath(parts []string) string { + result := "" + for _, p := range parts { + result += "/" + p // new allocation each iteration + } + return result +} + +// GOOD: O(n) with single allocation +func buildPath(parts []string) string { + if len(parts) == 0 { + return "" + } + n := len(parts) // for slashes + for _, p := range parts { + n += len(p) + } + + b := make([]byte, 0, n) + for _, p := range parts { + b = append(b, '/') + b = append(b, p...) + } + return string(b) +} +``` + +### Unsafe String/Byte Conversion + +```go +import "unsafe" + +// Zero-allocation string to []byte (read-only!) +func unsafeBytes(s string) []byte { + return unsafe.Slice(unsafe.StringData(s), len(s)) +} + +// Zero-allocation []byte to string (b must not be modified!) +func unsafeString(b []byte) string { + return unsafe.String(unsafe.SliceData(b), len(b)) +} + +// Use when: +// 1. Converting string for read-only operations (hashing, comparison) +// 2. Returning []byte from buffer that won't be modified +// 3. Performance-critical paths with careful ownership management +``` + +## Slice Capacity Management + +### Append Growth Patterns + +```go +// Slice growth: 0 -> 1 -> 2 -> 4 -> 8 -> 16 -> 32 -> 64 -> ... +// After 1024: grows by 25% each time + +// BAD: Unknown final size causes multiple reallocations +func collectItems() []Item { + var items []Item + for item := range source { + items = append(items, item) // may reallocate multiple times + } + return items +} + +// GOOD: Preallocate when size is known +func collectItems(n int) []Item { + items := make([]Item, 0, n) + for item := range source { + items = append(items, item) + } + return items +} + +// GOOD: Use slice header trick for uncertain sizes +func collectItems() []Item { + items := make([]Item, 0, 32) // reasonable initial capacity + for item := range source { + items = append(items, item) + } + // Trim excess capacity if items will be long-lived + return items[:len(items):len(items)] +} +``` + +### Slice Recycling + +```go +// Reuse slice backing array +func processInBatches(items []Item, batchSize int) { + batch := make([]Item, 0, batchSize) + + for i, item := range items { + batch = append(batch, item) + + if len(batch) == batchSize || i == len(items)-1 { + processBatch(batch) + batch = batch[:0] // reset length, keep capacity + } + } +} +``` + +### Preventing Slice Memory Leaks + +```go +// BAD: Subslice keeps entire backing array alive +func getFirst10(data []byte) []byte { + return data[:10] // entire data array stays in memory +} + +// GOOD: Copy to release original array +func getFirst10(data []byte) []byte { + result := make([]byte, 10) + copy(result, data[:10]) + return result +} + +// Alternative: explicit capacity limit +func getFirst10(data []byte) []byte { + return data[:10:10] // cap=10, can't accidentally grow into original +} +``` + +## Struct Layout Optimization + +### Field Ordering for Alignment + +```go +// BAD: 32 bytes due to padding +type BadLayout struct { + a bool // 1 byte + 7 padding + b int64 // 8 bytes + c bool // 1 byte + 7 padding + d int64 // 8 bytes +} + +// GOOD: 24 bytes with optimal ordering +type GoodLayout struct { + b int64 // 8 bytes + d int64 // 8 bytes + a bool // 1 byte + c bool // 1 byte + 6 padding +} + +// Rule: Order fields from largest to smallest alignment +``` + +### Checking Struct Size + +```go +func init() { + // Compile-time size assertions + var _ [24]byte = [unsafe.Sizeof(GoodLayout{})]byte{} + + // Or runtime check + if unsafe.Sizeof(Event{}) > 256 { + panic("Event struct too large") + } +} +``` + +### Cache-Line Optimization + +```go +const CacheLineSize = 64 + +// Pad struct to prevent false sharing in concurrent access +type PaddedCounter struct { + value uint64 + _ [CacheLineSize - 8]byte // padding +} + +type Counters struct { + reads PaddedCounter + writes PaddedCounter + // Each counter on separate cache line +} +``` + +## Object Reuse Patterns + +### Reset Methods + +```go +type Request struct { + Method string + Path string + Headers map[string]string + Body []byte +} + +func (r *Request) Reset() { + r.Method = "" + r.Path = "" + // Reuse map, just clear entries + for k := range r.Headers { + delete(r.Headers, k) + } + r.Body = r.Body[:0] +} + +var requestPool = sync.Pool{ + New: func() interface{} { + return &Request{ + Headers: make(map[string]string, 8), + Body: make([]byte, 0, 1024), + } + }, +} +``` + +### Flyweight Pattern + +```go +// Share immutable parts across many instances +type Event struct { + kind *Kind // shared, immutable + content string +} + +type Kind struct { + ID int + Name string + Description string +} + +var kindRegistry = map[int]*Kind{ + 0: {0, "set_metadata", "User metadata"}, + 1: {1, "text_note", "Text note"}, + // ... pre-allocated, shared across all events +} + +func NewEvent(kindID int, content string) Event { + return Event{ + kind: kindRegistry[kindID], // no allocation + content: content, + } +} +``` + +## Channel Patterns for Memory Efficiency + +### Buffered Channels as Object Pools + +```go +type SimplePool struct { + pool chan *Buffer +} + +func NewSimplePool(size int) *SimplePool { + p := &SimplePool{pool: make(chan *Buffer, size)} + for i := 0; i < size; i++ { + p.pool <- NewBuffer() + } + return p +} + +func (p *SimplePool) Get() *Buffer { + select { + case b := <-p.pool: + return b + default: + return NewBuffer() // pool empty, allocate new + } +} + +func (p *SimplePool) Put(b *Buffer) { + select { + case p.pool <- b: + default: + // pool full, let GC collect + } +} +``` + +### Batch Processing Channels + +```go +// Reduce channel overhead by batching +func batchProcessor(input <-chan Item, batchSize int) <-chan []Item { + output := make(chan []Item) + go func() { + defer close(output) + batch := make([]Item, 0, batchSize) + + for item := range input { + batch = append(batch, item) + if len(batch) == batchSize { + output <- batch + batch = make([]Item, 0, batchSize) + } + } + if len(batch) > 0 { + output <- batch + } + }() + return output +} +``` + +## Advanced Techniques + +### Manual Memory Management with mmap + +```go +import "golang.org/x/sys/unix" + +// Allocate memory outside Go heap +func allocateMmap(size int) ([]byte, error) { + data, err := unix.Mmap(-1, 0, size, + unix.PROT_READ|unix.PROT_WRITE, + unix.MAP_ANON|unix.MAP_PRIVATE) + return data, err +} + +func freeMmap(data []byte) error { + return unix.Munmap(data) +} +``` + +### Inline Arrays in Structs + +```go +// Small-size optimization: inline for small, pointer for large +type SmallVec struct { + len int + small [8]int // inline storage for ≤8 elements + large []int // heap storage for >8 elements +} + +func (v *SmallVec) Append(x int) { + if v.large != nil { + v.large = append(v.large, x) + v.len++ + return + } + if v.len < 8 { + v.small[v.len] = x + v.len++ + return + } + // Spill to heap + v.large = make([]int, 9, 16) + copy(v.large, v.small[:]) + v.large[8] = x + v.len++ +} +``` + +### Bump Allocator + +```go +// Simple arena-style allocator for batch allocations +type BumpAllocator struct { + buf []byte + off int +} + +func NewBumpAllocator(size int) *BumpAllocator { + return &BumpAllocator{buf: make([]byte, size)} +} + +func (a *BumpAllocator) Alloc(size int) []byte { + if a.off+size > len(a.buf) { + panic("bump allocator exhausted") + } + b := a.buf[a.off : a.off+size] + a.off += size + return b +} + +func (a *BumpAllocator) Reset() { + a.off = 0 +} + +// Usage: allocate many small objects, reset all at once +func processBatch(items []Item) { + arena := NewBumpAllocator(1 << 20) // 1MB + defer arena.Reset() + + for _, item := range items { + buf := arena.Alloc(item.Size()) + item.Serialize(buf) + } +} +``` diff --git a/.claude/skills/golang/SKILL.md b/.claude/skills/golang/SKILL.md new file mode 100644 index 00000000..d30ca64e --- /dev/null +++ b/.claude/skills/golang/SKILL.md @@ -0,0 +1,268 @@ +--- +name: golang +description: This skill should be used when writing, debugging, reviewing, or discussing Go (Golang) code. Provides comprehensive Go programming expertise including idiomatic patterns, standard library, concurrency, error handling, testing, and best practices based on official go.dev documentation. +--- + +# Go Programming Expert + +## Purpose + +This skill provides expert-level assistance with Go programming language development, covering language fundamentals, idiomatic patterns, concurrency, error handling, standard library usage, testing, and best practices. + +## When to Use + +Activate this skill when: +- Writing Go code +- Debugging Go programs +- Reviewing Go code for best practices +- Answering questions about Go language features +- Implementing Go-specific patterns (goroutines, channels, interfaces) +- Setting up Go projects and modules +- Writing Go tests + +## Core Principles + +When writing Go code, always follow these principles: + +1. **Named Return Variables**: ALWAYS use named return variables and prefer naked returns for cleaner code +2. **Error Handling**: Use `lol.mleku.dev/log` and the `chk/errorf` for error checking and creating new errors +3. **Idiomatic Code**: Write clear, idiomatic Go code following Effective Go guidelines +4. **Simplicity**: Favor simplicity and clarity over cleverness +5. **Composition**: Prefer composition over inheritance +6. **Explicit**: Be explicit rather than implicit + +## Key Go Concepts + +### Functions with Named Returns + +Always use named return values: +```go +func divide(a, b float64) (result float64, err error) { + if b == 0 { + err = errorf.New("division by zero") + return + } + result = a / b + return +} +``` + +### Error Handling + +Use the specified error handling packages: +```go +import "lol.mleku.dev/log" + +// Error checking with chk +if err := doSomething(); chk.E(err) { + return +} + +// Creating errors with errorf +err := errorf.New("something went wrong") +err := errorf.Errorf("failed to process: %v", value) +``` + +### Interfaces and Composition + +Go uses implicit interface implementation: +```go +type Reader interface { + Read(p []byte) (n int, err error) +} + +// Any type with a Read method implements Reader +type File struct { + name string +} + +func (f *File) Read(p []byte) (n int, err error) { + // Implementation + return +} +``` + +### Interface Design - CRITICAL RULES + +**Rule 1: Define interfaces in a dedicated package (e.g., `pkg/interfaces//`)** +- Interfaces provide isolation between packages and enable dependency inversion +- Keeping interfaces in a dedicated package prevents circular dependencies +- Each interface package should be minimal (just the interface, no implementations) + +**Rule 2: NEVER use type assertions with interface literals** +- **NEVER** write `.(interface{ Method() Type })` - this is non-idiomatic and unmaintainable +- Interface literals cannot be documented, tested for satisfaction, or reused + +```go +// BAD - interface literal in type assertion (NEVER DO THIS) +if checker, ok := obj.(interface{ Check() bool }); ok { + checker.Check() +} + +// GOOD - use defined interface from dedicated package +import "myproject/pkg/interfaces/checker" + +if c, ok := obj.(checker.Checker); ok { + c.Check() +} +``` + +**Rule 3: Resolving Circular Dependencies** +- If a circular dependency occurs, move the interface to `pkg/interfaces/` +- The implementing type stays in its original package +- The consuming code imports only the interface package +- Pattern: + ``` + pkg/interfaces/foo/ <- interface definition (no dependencies) + ↑ ↑ + pkg/bar/ pkg/baz/ + (implements) (consumes via interface) + ``` + +**Rule 4: Verify interface satisfaction at compile time** +```go +// Add this line to ensure *MyType implements MyInterface +var _ MyInterface = (*MyType)(nil) +``` + +### Concurrency + +Use goroutines and channels for concurrent programming: +```go +// Launch goroutine +go doWork() + +// Channels +ch := make(chan int, 10) +ch <- 42 +value := <-ch + +// Select statement +select { +case msg := <-ch1: + // Handle +case <-time.After(time.Second): + // Timeout +} + +// Sync primitives +var mu sync.Mutex +mu.Lock() +defer mu.Unlock() +``` + +### Testing + +Use table-driven tests as the default pattern: +```go +func TestAdd(t *testing.T) { + tests := []struct { + name string + a, b int + expected int + }{ + {"positive", 2, 3, 5}, + {"negative", -1, -1, -2}, + {"zero", 0, 5, 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Add(tt.a, tt.b) + if result != tt.expected { + t.Errorf("got %d, want %d", result, tt.expected) + } + }) + } +} +``` + +## Reference Materials + +For detailed information, consult the reference files: + +- **references/effective-go-summary.md** - Key points from Effective Go including formatting, naming, control structures, functions, data allocation, methods, interfaces, concurrency principles, and error handling philosophy + +- **references/common-patterns.md** - Practical Go patterns including: + - Design patterns (Functional Options, Builder, Singleton, Factory, Strategy) + - Concurrency patterns (Worker Pool, Pipeline, Fan-Out/Fan-In, Timeout, Rate Limiting, Circuit Breaker) + - Error handling patterns (Error Wrapping, Sentinel Errors, Custom Error Types) + - Resource management patterns + - Testing patterns + +- **references/quick-reference.md** - Quick syntax cheatsheet with common commands, format verbs, standard library snippets, and best practices checklist + +## Best Practices Summary + +1. **Naming Conventions** + - Use camelCase for variables and functions + - Use PascalCase for exported names + - Keep names short but descriptive + - Interface names often end in -er (Reader, Writer, Handler) + +2. **Error Handling** + - Always check errors + - Use named return values + - Use lol.mleku.dev/log and chk/errorf + +3. **Code Organization** + - One package per directory + - Use internal/ for non-exported packages + - Use cmd/ for applications + - Use pkg/ for reusable libraries + +4. **Concurrency** + - Don't communicate by sharing memory; share memory by communicating + - Always close channels from sender + - Use defer for cleanup + +5. **Documentation** + - Comment all exported names + - Start comments with the name being described + - Use godoc format + +6. **Configuration - CRITICAL** + - **NEVER** use `os.Getenv()` scattered throughout packages + - **ALWAYS** centralize environment variable parsing in a single config package (e.g., `app/config/`) + - Pass configuration via structs, not by reading environment directly + - This ensures discoverability, documentation, and testability of all config options + +7. **Constants - CRITICAL** + - **ALWAYS** define named constants for values used more than a few times + - **ALWAYS** define named constants if multiple packages depend on the same value + - Constants shared across packages belong in a dedicated package (e.g., `pkg/constants/`) + - Magic numbers and strings are forbidden + ```go + // BAD - magic number + if size > 1024 { + + // GOOD - named constant + const MaxBufferSize = 1024 + if size > MaxBufferSize { + ``` + +## Common Commands + +```bash +go run main.go # Run program +go build # Compile +go test # Run tests +go test -v # Verbose tests +go test -cover # Test coverage +go test -race # Race detection +go fmt # Format code +go vet # Lint code +go mod tidy # Clean dependencies +go get package # Add dependency +``` + +## Official Resources + +All guidance is based on official Go documentation: +- Go Website: https://go.dev +- Documentation: https://go.dev/doc/ +- Effective Go: https://go.dev/doc/effective_go +- Language Specification: https://go.dev/ref/spec +- Standard Library: https://pkg.go.dev/std +- Go Tour: https://go.dev/tour/ + diff --git a/.claude/skills/golang/references/common-patterns.md b/.claude/skills/golang/references/common-patterns.md new file mode 100644 index 00000000..1ecb3ca5 --- /dev/null +++ b/.claude/skills/golang/references/common-patterns.md @@ -0,0 +1,649 @@ +# Go Common Patterns and Idioms + +## Design Patterns + +### Functional Options Pattern + +Used for configuring objects with many optional parameters: + +```go +type Server struct { + host string + port int + timeout time.Duration + maxConn int +} + +type Option func(*Server) + +func WithHost(host string) Option { + return func(s *Server) { + s.host = host + } +} + +func WithPort(port int) Option { + return func(s *Server) { + s.port = port + } +} + +func WithTimeout(timeout time.Duration) Option { + return func(s *Server) { + s.timeout = timeout + } +} + +func NewServer(opts ...Option) *Server { + // Set defaults + s := &Server{ + host: "localhost", + port: 8080, + timeout: 30 * time.Second, + maxConn: 100, + } + + // Apply options + for _, opt := range opts { + opt(s) + } + + return s +} + +// Usage +srv := NewServer( + WithHost("example.com"), + WithPort(443), + WithTimeout(60 * time.Second), +) +``` + +### Builder Pattern + +For complex object construction: + +```go +type HTTPRequest struct { + method string + url string + headers map[string]string + body []byte +} + +type RequestBuilder struct { + request *HTTPRequest +} + +func NewRequestBuilder() *RequestBuilder { + return &RequestBuilder{ + request: &HTTPRequest{ + headers: make(map[string]string), + }, + } +} + +func (b *RequestBuilder) Method(method string) *RequestBuilder { + b.request.method = method + return b +} + +func (b *RequestBuilder) URL(url string) *RequestBuilder { + b.request.url = url + return b +} + +func (b *RequestBuilder) Header(key, value string) *RequestBuilder { + b.request.headers[key] = value + return b +} + +func (b *RequestBuilder) Body(body []byte) *RequestBuilder { + b.request.body = body + return b +} + +func (b *RequestBuilder) Build() *HTTPRequest { + return b.request +} + +// Usage +req := NewRequestBuilder(). + Method("POST"). + URL("https://api.example.com"). + Header("Content-Type", "application/json"). + Body([]byte(`{"key":"value"}`)). + Build() +``` + +### Singleton Pattern + +Thread-safe singleton using sync.Once: + +```go +type Database struct { + conn *sql.DB +} + +var ( + instance *Database + once sync.Once +) + +func GetDatabase() *Database { + once.Do(func() { + conn, err := sql.Open("postgres", "connection-string") + if err != nil { + log.Fatal(err) + } + instance = &Database{conn: conn} + }) + return instance +} +``` + +### Factory Pattern + +```go +type Animal interface { + Speak() string +} + +type Dog struct{} +func (d Dog) Speak() string { return "Woof!" } + +type Cat struct{} +func (c Cat) Speak() string { return "Meow!" } + +type AnimalFactory struct{} + +func (f *AnimalFactory) CreateAnimal(animalType string) Animal { + switch animalType { + case "dog": + return &Dog{} + case "cat": + return &Cat{} + default: + return nil + } +} +``` + +### Strategy Pattern + +```go +type PaymentStrategy interface { + Pay(amount float64) error +} + +type CreditCard struct { + number string +} + +func (c *CreditCard) Pay(amount float64) error { + fmt.Printf("Paying %.2f using credit card %s\n", amount, c.number) + return nil +} + +type PayPal struct { + email string +} + +func (p *PayPal) Pay(amount float64) error { + fmt.Printf("Paying %.2f using PayPal account %s\n", amount, p.email) + return nil +} + +type PaymentContext struct { + strategy PaymentStrategy +} + +func (pc *PaymentContext) SetStrategy(strategy PaymentStrategy) { + pc.strategy = strategy +} + +func (pc *PaymentContext) ExecutePayment(amount float64) error { + return pc.strategy.Pay(amount) +} +``` + +## Concurrency Patterns + +### Worker Pool + +```go +func worker(id int, jobs <-chan Job, results chan<- Result) { + for job := range jobs { + result := processJob(job) + results <- result + } +} + +func WorkerPool(numWorkers int, jobs []Job) []Result { + jobsChan := make(chan Job, len(jobs)) + results := make(chan Result, len(jobs)) + + // Start workers + for w := 1; w <= numWorkers; w++ { + go worker(w, jobsChan, results) + } + + // Send jobs + for _, job := range jobs { + jobsChan <- job + } + close(jobsChan) + + // Collect results + var output []Result + for range jobs { + output = append(output, <-results) + } + + return output +} +``` + +### Pipeline Pattern + +```go +func generator(nums ...int) <-chan int { + out := make(chan int) + go func() { + for _, n := range nums { + out <- n + } + close(out) + }() + return out +} + +func square(in <-chan int) <-chan int { + out := make(chan int) + go func() { + for n := range in { + out <- n * n + } + close(out) + }() + return out +} + +func main() { + // Create pipeline + c := generator(2, 3, 4) + out := square(c) + + // Consume output + for result := range out { + fmt.Println(result) + } +} +``` + +### Fan-Out, Fan-In + +```go +func fanOut(in <-chan int, n int) []<-chan int { + channels := make([]<-chan int, n) + for i := 0; i < n; i++ { + channels[i] = worker(in) + } + return channels +} + +func worker(in <-chan int) <-chan int { + out := make(chan int) + go func() { + for n := range in { + out <- expensiveOperation(n) + } + close(out) + }() + return out +} + +func fanIn(channels ...<-chan int) <-chan int { + out := make(chan int) + var wg sync.WaitGroup + + wg.Add(len(channels)) + for _, c := range channels { + go func(ch <-chan int) { + defer wg.Done() + for n := range ch { + out <- n + } + }(c) + } + + go func() { + wg.Wait() + close(out) + }() + + return out +} +``` + +### Timeout Pattern + +```go +func DoWithTimeout(timeout time.Duration) (result string, err error) { + done := make(chan struct{}) + + go func() { + result = expensiveOperation() + close(done) + }() + + select { + case <-done: + return result, nil + case <-time.After(timeout): + return "", fmt.Errorf("operation timed out after %v", timeout) + } +} +``` + +### Graceful Shutdown + +```go +func main() { + server := &http.Server{Addr: ":8080"} + + // Start server in goroutine + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + // Graceful shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + + log.Println("Server exiting") +} +``` + +### Rate Limiting + +```go +func rateLimiter(rate time.Duration) <-chan time.Time { + return time.Tick(rate) +} + +func main() { + limiter := rateLimiter(200 * time.Millisecond) + + for req := range requests { + <-limiter // Wait for rate limiter + go handleRequest(req) + } +} +``` + +### Circuit Breaker + +```go +type CircuitBreaker struct { + maxFailures int + timeout time.Duration + failures int + lastFail time.Time + state string + mu sync.Mutex +} + +func (cb *CircuitBreaker) Call(fn func() error) error { + cb.mu.Lock() + defer cb.mu.Unlock() + + if cb.state == "open" { + if time.Since(cb.lastFail) > cb.timeout { + cb.state = "half-open" + } else { + return fmt.Errorf("circuit breaker is open") + } + } + + err := fn() + if err != nil { + cb.failures++ + cb.lastFail = time.Now() + if cb.failures >= cb.maxFailures { + cb.state = "open" + } + return err + } + + cb.failures = 0 + cb.state = "closed" + return nil +} +``` + +## Error Handling Patterns + +### Error Wrapping + +```go +func processFile(filename string) (err error) { + data, err := readFile(filename) + if err != nil { + return fmt.Errorf("failed to process file %s: %w", filename, err) + } + + if err := validate(data); err != nil { + return fmt.Errorf("validation failed for %s: %w", filename, err) + } + + return nil +} +``` + +### Sentinel Errors + +```go +var ( + ErrNotFound = errors.New("not found") + ErrUnauthorized = errors.New("unauthorized") + ErrInvalidInput = errors.New("invalid input") +) + +func FindUser(id int) (*User, error) { + user, exists := users[id] + if !exists { + return nil, ErrNotFound + } + return user, nil +} + +// Check error +user, err := FindUser(123) +if errors.Is(err, ErrNotFound) { + // Handle not found +} +``` + +### Custom Error Types + +```go +type ValidationError struct { + Field string + Value interface{} + Err error +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation failed for field %s with value %v: %v", + e.Field, e.Value, e.Err) +} + +func (e *ValidationError) Unwrap() error { + return e.Err +} + +// Usage +var validErr *ValidationError +if errors.As(err, &validErr) { + fmt.Printf("Field: %s\n", validErr.Field) +} +``` + +## Resource Management Patterns + +### Defer for Cleanup + +```go +func processFile(filename string) error { + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + + // Process file + return nil +} +``` + +### Context for Cancellation + +```go +func fetchData(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return io.ReadAll(resp.Body) +} +``` + +### Sync.Pool for Object Reuse + +```go +var bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func process() { + buf := bufferPool.Get().(*bytes.Buffer) + defer bufferPool.Put(buf) + + buf.Reset() + // Use buffer +} +``` + +## Testing Patterns + +### Table-Driven Tests + +```go +func TestAdd(t *testing.T) { + tests := []struct { + name string + a, b int + expected int + }{ + {"positive numbers", 2, 3, 5}, + {"negative numbers", -1, -1, -2}, + {"mixed signs", -5, 10, 5}, + {"zeros", 0, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Add(tt.a, tt.b) + if result != tt.expected { + t.Errorf("Add(%d, %d) = %d; want %d", + tt.a, tt.b, result, tt.expected) + } + }) + } +} +``` + +### Mock Interfaces + +```go +type Database interface { + Get(key string) (string, error) + Set(key, value string) error +} + +type MockDB struct { + data map[string]string +} + +func (m *MockDB) Get(key string) (string, error) { + val, ok := m.data[key] + if !ok { + return "", errors.New("not found") + } + return val, nil +} + +func (m *MockDB) Set(key, value string) error { + m.data[key] = value + return nil +} + +func TestUserService(t *testing.T) { + mockDB := &MockDB{data: make(map[string]string)} + service := NewUserService(mockDB) + // Test service +} +``` + +### Test Fixtures + +```go +func setupTestDB(t *testing.T) (*sql.DB, func()) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + + // Setup schema + _, err = db.Exec(schema) + if err != nil { + t.Fatal(err) + } + + cleanup := func() { + db.Close() + } + + return db, cleanup +} + +func TestDatabase(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Run tests +} +``` + diff --git a/.claude/skills/golang/references/effective-go-summary.md b/.claude/skills/golang/references/effective-go-summary.md new file mode 100644 index 00000000..b138062b --- /dev/null +++ b/.claude/skills/golang/references/effective-go-summary.md @@ -0,0 +1,423 @@ +# Effective Go - Key Points Summary + +Source: https://go.dev/doc/effective_go + +## Formatting + +- Use `gofmt` to automatically format your code +- Indentation: use tabs +- Line length: no strict limit, but keep reasonable +- Parentheses: Go uses fewer parentheses than C/Java + +## Commentary + +- Every package should have a package comment +- Every exported name should have a doc comment +- Comments should be complete sentences +- Start comments with the name of the element being described + +Example: +```go +// Package regexp implements regular expression search. +package regexp + +// Compile parses a regular expression and returns, if successful, +// a Regexp object that can be used to match against text. +func Compile(str string) (*Regexp, error) { +``` + +## Names + +### Package Names +- Short, concise, evocative +- Lowercase, single-word +- No underscores or mixedCaps +- Avoid stuttering (e.g., `bytes.Buffer` not `bytes.ByteBuffer`) + +### Getters/Setters +- Getter: `Owner()` not `GetOwner()` +- Setter: `SetOwner()` + +### Interface Names +- One-method interfaces use method name + -er suffix +- Examples: `Reader`, `Writer`, `Formatter`, `CloseNotifier` + +### MixedCaps +- Use `MixedCaps` or `mixedCaps` rather than underscores + +## Semicolons + +- Lexer automatically inserts semicolons +- Never put opening brace on its own line + +## Control Structures + +### If +```go +if err := file.Chmod(0664); err != nil { + log.Print(err) + return err +} +``` + +### Redeclaration +```go +f, err := os.Open(name) +// err is declared here + +d, err := f.Stat() +// err is redeclared here (same scope) +``` + +### For +```go +// Like a C for +for init; condition; post { } + +// Like a C while +for condition { } + +// Like a C for(;;) +for { } + +// Range over array/slice/map/channel +for key, value := range oldMap { + newMap[key] = value +} + +// If you only need the key +for key := range m { + // ... +} + +// If you only need the value +for _, value := range array { + // ... +} +``` + +### Switch +- No automatic fall through +- Cases can be expressions +- Can switch on no value (acts like if-else chain) + +```go +switch { +case '0' <= c && c <= '9': + return c - '0' +case 'a' <= c && c <= 'f': + return c - 'a' + 10 +case 'A' <= c && c <= 'F': + return c - 'A' + 10 +} +``` + +### Type Switch +```go +switch t := value.(type) { +case int: + fmt.Printf("int: %d\n", t) +case string: + fmt.Printf("string: %s\n", t) +default: + fmt.Printf("unexpected type %T\n", t) +} +``` + +## Functions + +### Multiple Return Values +```go +func (file *File) Write(b []byte) (n int, err error) { + // ... +} +``` + +### Named Result Parameters +- Named results are initialized to zero values +- Can be used for documentation +- Enable naked returns + +```go +func ReadFull(r Reader, buf []byte) (n int, err error) { + for len(buf) > 0 && err == nil { + var nr int + nr, err = r.Read(buf) + n += nr + buf = buf[nr:] + } + return +} +``` + +### Defer +- Schedules function call to run after surrounding function returns +- LIFO order +- Arguments evaluated when defer executes + +```go +func trace(s string) string { + fmt.Println("entering:", s) + return s +} + +func un(s string) { + fmt.Println("leaving:", s) +} + +func a() { + defer un(trace("a")) + fmt.Println("in a") +} +``` + +## Data + +### Allocation with new +- `new(T)` allocates zeroed storage for new item of type T +- Returns `*T` +- Returns memory address of newly allocated zero value + +```go +p := new(int) // p is *int, points to zeroed int +``` + +### Constructors and Composite Literals +```go +func NewFile(fd int, name string) *File { + if fd < 0 { + return nil + } + return &File{fd: fd, name: name} +} +``` + +### Allocation with make +- `make(T, args)` creates slices, maps, and channels only +- Returns initialized (not zeroed) value of type T (not *T) + +```go +make([]int, 10, 100) // slice: len=10, cap=100 +make(map[string]int) // map +make(chan int, 10) // buffered channel +``` + +### Arrays +- Arrays are values, not pointers +- Passing array to function copies the entire array +- Array size is part of its type + +### Slices +- Hold references to underlying array +- Can grow dynamically with `append` +- Passing slice passes reference + +### Maps +- Hold references to underlying data structure +- Passing map passes reference +- Zero value is `nil` + +### Printing +- `%v` - default format +- `%+v` - struct with field names +- `%#v` - Go syntax representation +- `%T` - type +- `%q` - quoted string + +## Initialization + +### Constants +- Created at compile time +- Can only be numbers, characters, strings, or booleans + +### init Function +- Each source file can have `init()` function +- Called after package-level variables initialized +- Used for setup that can't be expressed as declarations + +```go +func init() { + // initialization code +} +``` + +## Methods + +### Pointers vs. Values +- Value methods can be invoked on pointers and values +- Pointer methods can only be invoked on pointers + +Rule: Value methods can be called on both values and pointers, but pointer methods should only be called on pointers (though Go allows calling on addressable values). + +```go +type ByteSlice []byte + +func (slice ByteSlice) Append(data []byte) []byte { + // ... +} + +func (p *ByteSlice) Append(data []byte) { + slice := *p + // ... + *p = slice +} +``` + +## Interfaces and Other Types + +### Interfaces +- A type implements an interface by implementing its methods +- No explicit declaration of intent + +### Type Assertions +```go +value, ok := str.(string) +``` + +### Type Switches +```go +switch v := value.(type) { +case string: + // v is string +case int: + // v is int +} +``` + +### Generality +- If a type exists only to implement an interface and will never have exported methods beyond that interface, there's no need to export the type itself + +## The Blank Identifier + +### Unused Imports and Variables +```go +import _ "net/http/pprof" // Import for side effects +``` + +### Interface Checks +```go +var _ json.Marshaler = (*RawMessage)(nil) +``` + +## Embedding + +### Composition, not Inheritance +```go +type ReadWriter struct { + *Reader // *bufio.Reader + *Writer // *bufio.Writer +} +``` + +## Concurrency + +### Share by Communicating +- Don't communicate by sharing memory; share memory by communicating +- Use channels to pass ownership + +### Goroutines +- Cheap: small initial stack +- Multiplexed onto OS threads +- Prefix function call with `go` keyword + +### Channels +- Allocate with `make` +- Unbuffered: synchronous +- Buffered: asynchronous up to buffer size + +```go +ci := make(chan int) // unbuffered +cj := make(chan int, 0) // unbuffered +cs := make(chan *os.File, 100) // buffered +``` + +### Channels of Channels +```go +type Request struct { + args []int + f func([]int) int + resultChan chan int +} +``` + +### Parallelization +```go +const numCPU = runtime.NumCPU() +runtime.GOMAXPROCS(numCPU) +``` + +## Errors + +### Error Type +```go +type error interface { + Error() string +} +``` + +### Custom Errors +```go +type PathError struct { + Op string + Path string + Err error +} + +func (e *PathError) Error() string { + return e.Op + " " + e.Path + ": " + e.Err.Error() +} +``` + +### Panic +- Use for unrecoverable errors +- Generally avoid in library code + +### Recover +- Called inside deferred function +- Stops panic sequence +- Returns value passed to panic + +```go +func server(workChan <-chan *Work) { + for work := range workChan { + go safelyDo(work) + } +} + +func safelyDo(work *Work) { + defer func() { + if err := recover(); err != nil { + log.Println("work failed:", err) + } + }() + do(work) +} +``` + +## A Web Server Example + +```go +package main + +import ( + "fmt" + "log" + "net/http" +) + +type Counter struct { + n int +} + +func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) { + ctr.n++ + fmt.Fprintf(w, "counter = %d\n", ctr.n) +} + +func main() { + ctr := new(Counter) + http.Handle("/counter", ctr) + log.Fatal(http.ListenAndServe(":8080", nil)) +} +``` + diff --git a/.claude/skills/golang/references/quick-reference.md b/.claude/skills/golang/references/quick-reference.md new file mode 100644 index 00000000..c2e2a650 --- /dev/null +++ b/.claude/skills/golang/references/quick-reference.md @@ -0,0 +1,528 @@ +# Go Quick Reference Cheat Sheet + +## Basic Syntax + +### Hello World +```go +package main + +import "fmt" + +func main() { + fmt.Println("Hello, World!") +} +``` + +### Variables +```go +var name string = "John" +var age int = 30 +var height = 5.9 // type inference + +// Short declaration (inside functions only) +count := 42 +``` + +### Constants +```go +const Pi = 3.14159 +const ( + Sunday = iota // 0 + Monday // 1 + Tuesday // 2 +) +``` + +## Data Types + +### Basic Types +```go +bool // true, false +string // "hello" +int int8 int16 int32 int64 +uint uint8 uint16 uint32 uint64 +byte // alias for uint8 +rune // alias for int32 (Unicode) +float32 float64 +complex64 complex128 +``` + +### Composite Types +```go +// Array (fixed size) +var arr [5]int + +// Slice (dynamic) +slice := []int{1, 2, 3} +slice = append(slice, 4) + +// Map +m := make(map[string]int) +m["key"] = 42 + +// Struct +type Person struct { + Name string + Age int +} +p := Person{Name: "Alice", Age: 30} + +// Pointer +ptr := &p +``` + +## Functions + +```go +// Basic function +func add(a, b int) int { + return a + b +} + +// Named returns (preferred) +func divide(a, b float64) (result float64, err error) { + if b == 0 { + err = errors.New("division by zero") + return + } + result = a / b + return +} + +// Variadic +func sum(nums ...int) int { + total := 0 + for _, n := range nums { + total += n + } + return total +} + +// Multiple returns +func swap(a, b int) (int, int) { + return b, a +} +``` + +## Control Flow + +### If/Else +```go +if x > 0 { + // positive +} else if x < 0 { + // negative +} else { + // zero +} + +// With initialization +if err := doSomething(); err != nil { + return err +} +``` + +### For Loops +```go +// Traditional for +for i := 0; i < 10; i++ { + fmt.Println(i) +} + +// While-style +for condition { +} + +// Infinite +for { +} + +// Range +for i, v := range slice { + fmt.Printf("%d: %v\n", i, v) +} + +for key, value := range myMap { + fmt.Printf("%s: %v\n", key, value) +} +``` + +### Switch +```go +switch x { +case 1: + fmt.Println("one") +case 2, 3: + fmt.Println("two or three") +default: + fmt.Println("other") +} + +// Type switch +switch v := i.(type) { +case int: + fmt.Printf("int: %d\n", v) +case string: + fmt.Printf("string: %s\n", v) +} +``` + +## Methods & Interfaces + +### Methods +```go +type Rectangle struct { + Width, Height float64 +} + +// Value receiver +func (r Rectangle) Area() float64 { + return r.Width * r.Height +} + +// Pointer receiver +func (r *Rectangle) Scale(factor float64) { + r.Width *= factor + r.Height *= factor +} +``` + +### Interfaces +```go +type Shape interface { + Area() float64 + Perimeter() float64 +} + +// Empty interface (any type) +var x interface{} // or: var x any +``` + +## Concurrency + +### Goroutines +```go +go doSomething() + +go func() { + fmt.Println("In goroutine") +}() +``` + +### Channels +```go +// Create +ch := make(chan int) // unbuffered +ch := make(chan int, 10) // buffered + +// Send & Receive +ch <- 42 // send +value := <-ch // receive + +// Close +close(ch) + +// Check if closed +value, ok := <-ch +``` + +### Select +```go +select { +case msg := <-ch1: + fmt.Println("ch1:", msg) +case msg := <-ch2: + fmt.Println("ch2:", msg) +case <-time.After(1 * time.Second): + fmt.Println("timeout") +default: + fmt.Println("no channel ready") +} +``` + +### Sync Package +```go +// Mutex +var mu sync.Mutex +mu.Lock() +defer mu.Unlock() + +// RWMutex +var mu sync.RWMutex +mu.RLock() +defer mu.RUnlock() + +// WaitGroup +var wg sync.WaitGroup +wg.Add(1) +go func() { + defer wg.Done() + // work +}() +wg.Wait() +``` + +## Error Handling + +```go +// Create errors +err := errors.New("error message") +err := fmt.Errorf("failed: %w", originalErr) + +// Check errors +if err != nil { + return err +} + +// Custom error type +type MyError struct { + Msg string +} + +func (e *MyError) Error() string { + return e.Msg +} + +// Error checking (Go 1.13+) +if errors.Is(err, os.ErrNotExist) { + // handle +} + +var pathErr *os.PathError +if errors.As(err, &pathErr) { + // handle +} +``` + +## Standard Library Snippets + +### fmt - Formatting +```go +fmt.Print("text") +fmt.Println("text with newline") +fmt.Printf("Name: %s, Age: %d\n", name, age) +s := fmt.Sprintf("formatted %v", value) +``` + +### strings +```go +strings.Contains(s, substr) +strings.HasPrefix(s, prefix) +strings.Join([]string{"a", "b"}, ",") +strings.Split(s, ",") +strings.ToLower(s) +strings.TrimSpace(s) +``` + +### strconv +```go +i, _ := strconv.Atoi("42") +s := strconv.Itoa(42) +f, _ := strconv.ParseFloat("3.14", 64) +``` + +### io +```go +io.Copy(dst, src) +data, _ := io.ReadAll(r) +io.WriteString(w, "data") +``` + +### os +```go +file, _ := os.Open("file.txt") +defer file.Close() +os.Getenv("PATH") +os.Exit(1) +``` + +### net/http +```go +// Server +http.HandleFunc("/", handler) +http.ListenAndServe(":8080", nil) + +// Client +resp, _ := http.Get("https://example.com") +defer resp.Body.Close() +``` + +### encoding/json +```go +// Encode +data, _ := json.Marshal(obj) + +// Decode +json.Unmarshal(data, &obj) +``` + +### time +```go +now := time.Now() +time.Sleep(5 * time.Second) +t.Format("2006-01-02 15:04:05") +time.Parse("2006-01-02", "2024-01-01") +``` + +## Testing + +### Basic Test +```go +// mycode_test.go +package mypackage + +import "testing" + +func TestAdd(t *testing.T) { + result := Add(2, 3) + if result != 5 { + t.Errorf("got %d, want 5", result) + } +} +``` + +### Table-Driven Test +```go +func TestAdd(t *testing.T) { + tests := []struct { + name string + a, b int + expected int + }{ + {"positive", 2, 3, 5}, + {"negative", -1, -1, -2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Add(tt.a, tt.b) + if result != tt.expected { + t.Errorf("got %d, want %d", result, tt.expected) + } + }) + } +} +``` + +### Benchmark +```go +func BenchmarkAdd(b *testing.B) { + for i := 0; i < b.N; i++ { + Add(2, 3) + } +} +``` + +## Go Commands + +```bash +# Run +go run main.go + +# Build +go build +go build -o myapp + +# Test +go test +go test -v +go test -cover +go test -race + +# Format +go fmt ./... +gofmt -s -w . + +# Lint +go vet ./... + +# Modules +go mod init module-name +go mod tidy +go get package@version +go get -u ./... + +# Install +go install + +# Documentation +go doc package.Function +``` + +## Common Patterns + +### Defer +```go +file, err := os.Open("file.txt") +if err != nil { + return err +} +defer file.Close() +``` + +### Error Wrapping +```go +if err != nil { + return fmt.Errorf("failed to process: %w", err) +} +``` + +### Context +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() +``` + +### Options Pattern +```go +type Option func(*Config) + +func WithPort(port int) Option { + return func(c *Config) { + c.port = port + } +} + +func New(opts ...Option) *Server { + cfg := &Config{port: 8080} + for _, opt := range opts { + opt(cfg) + } + return &Server{cfg: cfg} +} +``` + +## Format Verbs + +```go +%v // default format +%+v // struct with field names +%#v // Go-syntax representation +%T // type +%t // bool +%d // decimal integer +%b // binary +%o // octal +%x // hex (lowercase) +%X // hex (uppercase) +%f // float +%e // scientific notation +%s // string +%q // quoted string +%p // pointer address +%w // error wrapping +``` + +## Best Practices + +1. Use `gofmt` to format code +2. Always check errors +3. Use named return values +4. Prefer composition over inheritance +5. Use defer for cleanup +6. Keep functions small and focused +7. Write table-driven tests +8. Document exported names +9. Use interfaces for flexibility +10. Follow Effective Go guidelines + diff --git a/.claude/skills/ndk/INDEX.md b/.claude/skills/ndk/INDEX.md new file mode 100644 index 00000000..41d6d98e --- /dev/null +++ b/.claude/skills/ndk/INDEX.md @@ -0,0 +1,286 @@ +# NDK (Nostr Development Kit) Claude Skill + +> **Comprehensive knowledge base for working with NDK in production applications** + +This Claude skill provides deep expertise in the Nostr Development Kit based on real-world usage patterns from the Plebeian Market application. + +## 📚 Documentation Structure + +``` +.claude/skills/ndk/ +├── README.md # This file - Overview and getting started +├── ndk-skill.md # Complete reference guide (18KB) +├── quick-reference.md # Fast lookup for common tasks (7KB) +├── troubleshooting.md # Common problems and solutions +└── examples/ # Production code examples + ├── README.md + ├── 01-initialization.ts # NDK setup and connection + ├── 02-authentication.ts # NIP-07, NIP-46, private keys + ├── 03-publishing-events.ts # Creating and publishing events + ├── 04-querying-subscribing.ts # Fetching and real-time subs + └── 05-users-profiles.ts # User and profile management +``` + +## 🚀 Quick Start + +### For Quick Lookups +Start with **`quick-reference.md`** for: +- Common code snippets +- Quick syntax reminders +- Frequently used patterns + +### For Deep Learning +Read **`ndk-skill.md`** for: +- Complete API documentation +- Best practices +- Integration patterns +- Performance optimization + +### For Problem Solving +Check **`troubleshooting.md`** for: +- Common error solutions +- Performance tips +- Testing strategies +- Debug techniques + +### For Code Examples +Browse **`examples/`** directory for: +- Real production code +- Full implementations +- React integration patterns +- Error handling examples + +## 📖 Core Topics Covered + +### 1. Initialization & Setup +- Basic NDK initialization +- Multiple instance patterns (main + zap relays) +- Connection management with timeouts +- Relay pool configuration +- Connection status monitoring + +### 2. Authentication +- **NIP-07**: Browser extension signers (Alby, nos2x) +- **NIP-46**: Remote signers (Bunker) +- **Private Keys**: Direct key management +- Auto-login with localStorage +- Multi-account session management + +### 3. Event Publishing +- Basic text notes +- Parameterized replaceable events (products, profiles) +- Order and payment events +- Batch publishing +- Error handling patterns + +### 4. Querying & Subscriptions +- One-time fetches with `fetchEvents()` +- Real-time subscriptions +- Tag filtering patterns +- Time-range queries +- Event monitoring +- React Query integration + +### 5. User & Profile Management +- Fetch profiles (npub, hex, NIP-05) +- Update user profiles +- Follow/unfollow operations +- Batch profile loading +- Profile caching strategies + +### 6. Advanced Patterns +- Store-based NDK management +- Query + subscription combination +- Event parsing utilities +- Memory leak prevention +- Performance optimization + +## 🎯 Use Cases + +### Building a Nostr Client +```typescript +// Initialize +const { ndk, isConnected } = await initializeNDK({ + relays: ['wss://relay.damus.io', 'wss://nos.lol'], + timeoutMs: 10000 +}) + +// Authenticate +const { user } = await loginWithExtension(ndk) + +// Publish +await publishBasicNote(ndk, 'Hello Nostr!') + +// Subscribe +const sub = subscribeToNotes(ndk, user.pubkey, (event) => { + console.log('New note:', event.content) +}) +``` + +### Building a Marketplace +```typescript +// Publish product +await publishProduct(ndk, { + slug: 'bitcoin-shirt', + title: 'Bitcoin T-Shirt', + price: 25, + currency: 'USD', + images: ['https://...'] +}) + +// Create order +await createOrder(ndk, { + orderId: uuidv4(), + sellerPubkey: merchant.pubkey, + productRef: '30402:pubkey:bitcoin-shirt', + quantity: 1, + totalAmount: '25.00' +}) + +// Monitor payment +monitorPaymentReceipt(ndk, orderId, invoiceId, (preimage) => { + console.log('Payment confirmed!') +}) +``` + +### React Integration +```typescript +function Feed() { + const ndk = useNDK() + const { user } = useAuth() + + // Query with real-time updates + const { data: notes } = useNotesWithSubscription( + ndk, + user.pubkey + ) + + return ( +
+ {notes?.map(note => ( + + ))} +
+ ) +} +``` + +## 🔍 Common Patterns Quick Reference + +### Safe NDK Access +```typescript +const ndk = ndkActions.getNDK() +if (!ndk) throw new Error('NDK not initialized') +``` + +### Subscription Cleanup +```typescript +useEffect(() => { + const sub = ndk.subscribe(filter, { closeOnEose: false }) + sub.on('event', handleEvent) + return () => sub.stop() // Critical! +}, [ndk]) +``` + +### Error Handling +```typescript +try { + await event.sign() + await event.publish() +} catch (error) { + console.error('Publishing failed:', error) + throw new Error('Failed to publish. Check connection.') +} +``` + +### Tag Filtering +```typescript +// ✅ Correct (note the # prefix for tag filters) +{ kinds: [16], '#order': [orderId] } + +// ❌ Wrong +{ kinds: [16], 'order': [orderId] } +``` + +## 🛠 Development Tools + +### VS Code Integration +These skill files work with: +- Cursor AI for code completion +- Claude for code assistance +- GitHub Copilot with context + +### Debugging Tips +```typescript +// Check connection +console.log('Connected relays:', + Array.from(ndk.pool?.relays.values() || []) + .filter(r => r.status === 1) + .map(r => r.url) +) + +// Verify signer +console.log('Signer:', ndk.signer) +console.log('Active user:', ndk.activeUser) + +// Event inspection +console.log('Event:', { + id: event.id, + kind: event.kind, + tags: event.tags, + sig: event.sig +}) +``` + +## 📊 Statistics + +- **Total Documentation**: ~50KB +- **Code Examples**: 5 complete modules +- **Patterns Documented**: 50+ +- **Common Issues Covered**: 15+ +- **Based On**: Real production code + +## 🔗 Additional Resources + +### Official NDK Resources +- **GitHub**: https://github.com/nostr-dev-kit/ndk +- **Documentation**: https://ndk.fyi +- **NPM**: `@nostr-dev-kit/ndk` + +### Nostr Protocol +- **NIPs**: https://github.com/nostr-protocol/nips +- **Nostr**: https://nostr.com + +### Related Tools +- **TanStack Query**: React state management +- **TanStack Router**: Type-safe routing +- **Radix UI**: Accessible components + +## 💡 Tips for Using This Skill + +1. **Start Small**: Begin with quick-reference.md for syntax +2. **Go Deep**: Read ndk-skill.md section by section +3. **Copy Examples**: Use examples/ as templates +4. **Debug Issues**: Check troubleshooting.md first +5. **Stay Updated**: Patterns based on production usage + +## 🤝 Contributing + +This skill is maintained based on the Plebeian Market codebase. To improve it: + +1. Document new patterns you discover +2. Add solutions to common problems +3. Update examples with better approaches +4. Keep synchronized with NDK updates + +## 📝 Version Info + +- **Skill Version**: 1.0.0 +- **NDK Version**: Latest (based on production usage) +- **Last Updated**: November 2025 +- **Codebase**: Plebeian Market + +--- + +**Ready to build with NDK?** Start with `quick-reference.md` or dive into `examples/01-initialization.ts`! + diff --git a/.claude/skills/ndk/README.md b/.claude/skills/ndk/README.md new file mode 100644 index 00000000..2a13dc47 --- /dev/null +++ b/.claude/skills/ndk/README.md @@ -0,0 +1,38 @@ +# NDK (Nostr Development Kit) Claude Skill + +This skill provides comprehensive knowledge about working with the Nostr Development Kit (NDK) library. + +## Files + +- **ndk-skill.md** - Complete reference documentation with patterns from production usage +- **quick-reference.md** - Quick lookup guide for common NDK tasks +- **examples/** - Code examples extracted from the Plebeian Market codebase + +## Usage + +When working with NDK-related code, reference these documents to: +- Understand initialization patterns +- Learn authentication flows (NIP-07, NIP-46, private keys) +- Implement event creation and publishing +- Set up subscriptions for real-time updates +- Query events with filters +- Handle users and profiles +- Integrate with TanStack Query + +## Key Topics Covered + +1. NDK Initialization & Configuration +2. Authentication & Signers +3. Event Creation & Publishing +4. Querying Events +5. Real-time Subscriptions +6. User & Profile Management +7. Tag Handling +8. Replaceable Events +9. Relay Management +10. Integration with React/TanStack Query +11. Error Handling & Best Practices +12. Performance Optimization + +All examples are based on real production code from the Plebeian Market application. + diff --git a/.claude/skills/ndk/examples/01-initialization.ts b/.claude/skills/ndk/examples/01-initialization.ts new file mode 100644 index 00000000..bada6d6b --- /dev/null +++ b/.claude/skills/ndk/examples/01-initialization.ts @@ -0,0 +1,162 @@ +/** + * NDK Initialization Patterns + * + * Examples from: src/lib/stores/ndk.ts + */ + +import NDK from '@nostr-dev-kit/ndk' + +// ============================================================ +// BASIC INITIALIZATION +// ============================================================ + +const basicInit = () => { + const ndk = new NDK({ + explicitRelayUrls: ['wss://relay.damus.io', 'wss://relay.nostr.band'] + }) + + return ndk +} + +// ============================================================ +// PRODUCTION PATTERN - WITH MULTIPLE NDK INSTANCES +// ============================================================ + +const productionInit = (relays: string[], zapRelays: string[]) => { + // Main NDK instance for general operations + const ndk = new NDK({ + explicitRelayUrls: relays + }) + + // Separate NDK for zap operations (performance optimization) + const zapNdk = new NDK({ + explicitRelayUrls: zapRelays + }) + + return { ndk, zapNdk } +} + +// ============================================================ +// CONNECTION WITH TIMEOUT +// ============================================================ + +const connectWithTimeout = async ( + ndk: NDK, + timeoutMs: number = 10000 +): Promise => { + // Create connection promise + const connectPromise = ndk.connect() + + // Create timeout promise + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection timeout')), timeoutMs) + ) + + try { + // Race between connection and timeout + await Promise.race([connectPromise, timeoutPromise]) + console.log('✅ NDK connected successfully') + } catch (error) { + if (error instanceof Error && error.message === 'Connection timeout') { + console.error('❌ Connection timed out after', timeoutMs, 'ms') + } else { + console.error('❌ Connection failed:', error) + } + throw error + } +} + +// ============================================================ +// FULL INITIALIZATION FLOW +// ============================================================ + +interface InitConfig { + relays?: string[] + zapRelays?: string[] + timeoutMs?: number +} + +const defaultRelays = [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://nos.lol' +] + +const defaultZapRelays = [ + 'wss://relay.damus.io', + 'wss://nostr.wine' +] + +const initializeNDK = async (config: InitConfig = {}) => { + const { + relays = defaultRelays, + zapRelays = defaultZapRelays, + timeoutMs = 10000 + } = config + + // Initialize instances + const ndk = new NDK({ explicitRelayUrls: relays }) + const zapNdk = new NDK({ explicitRelayUrls: zapRelays }) + + // Connect with timeout protection + try { + await connectWithTimeout(ndk, timeoutMs) + await connectWithTimeout(zapNdk, timeoutMs) + + return { ndk, zapNdk, isConnected: true } + } catch (error) { + return { ndk, zapNdk, isConnected: false, error } + } +} + +// ============================================================ +// CHECKING CONNECTION STATUS +// ============================================================ + +const getConnectionStatus = (ndk: NDK) => { + const connectedRelays = Array.from(ndk.pool?.relays.values() || []) + .filter(relay => relay.status === 1) + .map(relay => relay.url) + + const isConnected = connectedRelays.length > 0 + + return { + isConnected, + connectedRelays, + totalRelays: ndk.pool?.relays.size || 0 + } +} + +// ============================================================ +// USAGE EXAMPLE +// ============================================================ + +async function main() { + // Initialize + const { ndk, zapNdk, isConnected } = await initializeNDK({ + relays: defaultRelays, + zapRelays: defaultZapRelays, + timeoutMs: 10000 + }) + + if (!isConnected) { + console.error('Failed to connect to relays') + return + } + + // Check status + const status = getConnectionStatus(ndk) + console.log('Connection status:', status) + + // Ready to use + console.log('NDK ready for operations') +} + +export { + basicInit, + productionInit, + connectWithTimeout, + initializeNDK, + getConnectionStatus +} + diff --git a/.claude/skills/ndk/examples/02-authentication.ts b/.claude/skills/ndk/examples/02-authentication.ts new file mode 100644 index 00000000..2356205a --- /dev/null +++ b/.claude/skills/ndk/examples/02-authentication.ts @@ -0,0 +1,255 @@ +/** + * NDK Authentication Patterns + * + * Examples from: src/lib/stores/auth.ts + */ + +import NDK from '@nostr-dev-kit/ndk' +import { NDKNip07Signer, NDKPrivateKeySigner, NDKNip46Signer } from '@nostr-dev-kit/ndk' + +// ============================================================ +// NIP-07 - BROWSER EXTENSION SIGNER +// ============================================================ + +const loginWithExtension = async (ndk: NDK) => { + try { + // Create NIP-07 signer (browser extension like Alby, nos2x) + const signer = new NDKNip07Signer() + + // Wait for signer to be ready + await signer.blockUntilReady() + + // Set signer on NDK instance + ndk.signer = signer + + // Get authenticated user + const user = await signer.user() + + console.log('✅ Logged in via extension:', user.npub) + return { user, signer } + } catch (error) { + console.error('❌ Extension login failed:', error) + throw new Error('Failed to login with browser extension. Is it installed?') + } +} + +// ============================================================ +// PRIVATE KEY SIGNER +// ============================================================ + +const loginWithPrivateKey = async (ndk: NDK, privateKeyHex: string) => { + try { + // Validate private key format (64 hex characters) + if (!/^[0-9a-f]{64}$/.test(privateKeyHex)) { + throw new Error('Invalid private key format') + } + + // Create private key signer + const signer = new NDKPrivateKeySigner(privateKeyHex) + + // Wait for signer to be ready + await signer.blockUntilReady() + + // Set signer on NDK instance + ndk.signer = signer + + // Get authenticated user + const user = await signer.user() + + console.log('✅ Logged in with private key:', user.npub) + return { user, signer } + } catch (error) { + console.error('❌ Private key login failed:', error) + throw error + } +} + +// ============================================================ +// NIP-46 - REMOTE SIGNER (BUNKER) +// ============================================================ + +const loginWithNip46 = async ( + ndk: NDK, + bunkerUrl: string, + localPrivateKey?: string +) => { + try { + // Create or use existing local signer + const localSigner = localPrivateKey + ? new NDKPrivateKeySigner(localPrivateKey) + : NDKPrivateKeySigner.generate() + + // Create NIP-46 remote signer + const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner) + + // Wait for signer to be ready (may require user approval) + await remoteSigner.blockUntilReady() + + // Set signer on NDK instance + ndk.signer = remoteSigner + + // Get authenticated user + const user = await remoteSigner.user() + + console.log('✅ Logged in via NIP-46:', user.npub) + + // Store local signer key for reconnection + return { + user, + signer: remoteSigner, + localSignerKey: localSigner.privateKey + } + } catch (error) { + console.error('❌ NIP-46 login failed:', error) + throw error + } +} + +// ============================================================ +// AUTO-LOGIN FROM LOCAL STORAGE +// ============================================================ + +const STORAGE_KEYS = { + AUTO_LOGIN: 'nostr:auto-login', + LOCAL_SIGNER: 'nostr:local-signer', + BUNKER_URL: 'nostr:bunker-url', + ENCRYPTED_KEY: 'nostr:encrypted-key' +} + +const getAuthFromStorage = async (ndk: NDK) => { + try { + // Check if auto-login is enabled + const autoLogin = localStorage.getItem(STORAGE_KEYS.AUTO_LOGIN) + if (autoLogin !== 'true') { + return null + } + + // Try NIP-46 bunker connection + const privateKey = localStorage.getItem(STORAGE_KEYS.LOCAL_SIGNER) + const bunkerUrl = localStorage.getItem(STORAGE_KEYS.BUNKER_URL) + + if (privateKey && bunkerUrl) { + return await loginWithNip46(ndk, bunkerUrl, privateKey) + } + + // Try encrypted private key + const encryptedKey = localStorage.getItem(STORAGE_KEYS.ENCRYPTED_KEY) + if (encryptedKey) { + // Would need decryption password from user + return { needsPassword: true, encryptedKey } + } + + // Fallback to extension + return await loginWithExtension(ndk) + } catch (error) { + console.error('Auto-login failed:', error) + return null + } +} + +// ============================================================ +// SAVE AUTH TO STORAGE +// ============================================================ + +const saveAuthToStorage = ( + method: 'extension' | 'private-key' | 'nip46', + data?: { + privateKey?: string + bunkerUrl?: string + encryptedKey?: string + } +) => { + // Enable auto-login + localStorage.setItem(STORAGE_KEYS.AUTO_LOGIN, 'true') + + if (method === 'nip46' && data?.privateKey && data?.bunkerUrl) { + localStorage.setItem(STORAGE_KEYS.LOCAL_SIGNER, data.privateKey) + localStorage.setItem(STORAGE_KEYS.BUNKER_URL, data.bunkerUrl) + } else if (method === 'private-key' && data?.encryptedKey) { + localStorage.setItem(STORAGE_KEYS.ENCRYPTED_KEY, data.encryptedKey) + } + // Extension doesn't need storage +} + +// ============================================================ +// LOGOUT +// ============================================================ + +const logout = (ndk: NDK) => { + // Remove signer from NDK + ndk.signer = undefined + + // Clear all auth storage + Object.values(STORAGE_KEYS).forEach(key => { + localStorage.removeItem(key) + }) + + console.log('✅ Logged out successfully') +} + +// ============================================================ +// GET CURRENT USER +// ============================================================ + +const getCurrentUser = async (ndk: NDK) => { + if (!ndk.signer) { + return null + } + + try { + const user = await ndk.signer.user() + return { + pubkey: user.pubkey, + npub: user.npub, + profile: await user.fetchProfile() + } + } catch (error) { + console.error('Failed to get current user:', error) + return null + } +} + +// ============================================================ +// USAGE EXAMPLE +// ============================================================ + +async function authExample(ndk: NDK) { + // Try auto-login first + let auth = await getAuthFromStorage(ndk) + + if (!auth) { + // Manual login options + console.log('Choose login method:') + console.log('1. Browser Extension (NIP-07)') + console.log('2. Private Key') + console.log('3. Remote Signer (NIP-46)') + + // Example: login with extension + auth = await loginWithExtension(ndk) + saveAuthToStorage('extension') + } + + if (auth && 'needsPassword' in auth) { + // Handle encrypted key case + console.log('Password required for encrypted key') + return + } + + // Get current user info + const currentUser = await getCurrentUser(ndk) + console.log('Current user:', currentUser) + + // Logout when done + // logout(ndk) +} + +export { + loginWithExtension, + loginWithPrivateKey, + loginWithNip46, + getAuthFromStorage, + saveAuthToStorage, + logout, + getCurrentUser +} + diff --git a/.claude/skills/ndk/examples/03-publishing-events.ts b/.claude/skills/ndk/examples/03-publishing-events.ts new file mode 100644 index 00000000..bd068e43 --- /dev/null +++ b/.claude/skills/ndk/examples/03-publishing-events.ts @@ -0,0 +1,376 @@ +/** + * NDK Event Publishing Patterns + * + * Examples from: src/publish/orders.tsx, scripts/gen_products.ts + */ + +import NDK, { NDKEvent, NDKTag } from '@nostr-dev-kit/ndk' + +// ============================================================ +// BASIC EVENT PUBLISHING +// ============================================================ + +const publishBasicNote = async (ndk: NDK, content: string) => { + // Create event + const event = new NDKEvent(ndk) + event.kind = 1 // Text note + event.content = content + event.tags = [] + + // Sign and publish + await event.sign() + await event.publish() + + console.log('✅ Published note:', event.id) + return event.id +} + +// ============================================================ +// EVENT WITH TAGS +// ============================================================ + +const publishNoteWithTags = async ( + ndk: NDK, + content: string, + options: { + mentions?: string[] // pubkeys to mention + hashtags?: string[] + replyTo?: string // event ID + } +) => { + const event = new NDKEvent(ndk) + event.kind = 1 + event.content = content + event.tags = [] + + // Add mentions + if (options.mentions) { + options.mentions.forEach(pubkey => { + event.tags.push(['p', pubkey]) + }) + } + + // Add hashtags + if (options.hashtags) { + options.hashtags.forEach(tag => { + event.tags.push(['t', tag]) + }) + } + + // Add reply + if (options.replyTo) { + event.tags.push(['e', options.replyTo, '', 'reply']) + } + + await event.sign() + await event.publish() + + return event.id +} + +// ============================================================ +// PRODUCT LISTING (PARAMETERIZED REPLACEABLE EVENT) +// ============================================================ + +interface ProductData { + slug: string // Unique identifier + title: string + description: string + price: number + currency: string + images: string[] + shippingRefs?: string[] + category?: string +} + +const publishProduct = async (ndk: NDK, product: ProductData) => { + const event = new NDKEvent(ndk) + event.kind = 30402 // Product listing kind + event.content = product.description + + // Build tags + event.tags = [ + ['d', product.slug], // Unique identifier (required for replaceable) + ['title', product.title], + ['price', product.price.toString(), product.currency], + ] + + // Add images + product.images.forEach(image => { + event.tags.push(['image', image]) + }) + + // Add shipping options + if (product.shippingRefs) { + product.shippingRefs.forEach(ref => { + event.tags.push(['shipping', ref]) + }) + } + + // Add category + if (product.category) { + event.tags.push(['t', product.category]) + } + + // Optional: set custom timestamp + event.created_at = Math.floor(Date.now() / 1000) + + await event.sign() + await event.publish() + + console.log('✅ Published product:', product.title) + return event.id +} + +// ============================================================ +// ORDER CREATION EVENT +// ============================================================ + +interface OrderData { + orderId: string + sellerPubkey: string + productRef: string + quantity: number + totalAmount: string + currency: string + shippingRef?: string + shippingAddress?: string + email?: string + phone?: string + notes?: string +} + +const createOrder = async (ndk: NDK, order: OrderData) => { + const event = new NDKEvent(ndk) + event.kind = 16 // Order processing kind + event.content = order.notes || '' + + // Required tags per spec + event.tags = [ + ['p', order.sellerPubkey], + ['subject', `Order ${order.orderId.substring(0, 8)}`], + ['type', 'order-creation'], + ['order', order.orderId], + ['amount', order.totalAmount], + ['item', order.productRef, order.quantity.toString()], + ] + + // Optional tags + if (order.shippingRef) { + event.tags.push(['shipping', order.shippingRef]) + } + + if (order.shippingAddress) { + event.tags.push(['address', order.shippingAddress]) + } + + if (order.email) { + event.tags.push(['email', order.email]) + } + + if (order.phone) { + event.tags.push(['phone', order.phone]) + } + + try { + await event.sign() + await event.publish() + + console.log('✅ Order created:', order.orderId) + return { success: true, eventId: event.id } + } catch (error) { + console.error('❌ Failed to create order:', error) + return { success: false, error } + } +} + +// ============================================================ +// STATUS UPDATE EVENT +// ============================================================ + +const publishStatusUpdate = async ( + ndk: NDK, + orderId: string, + recipientPubkey: string, + status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled', + notes?: string +) => { + const event = new NDKEvent(ndk) + event.kind = 16 + event.content = notes || `Order status updated to ${status}` + event.tags = [ + ['p', recipientPubkey], + ['subject', 'order-info'], + ['type', 'status-update'], + ['order', orderId], + ['status', status], + ] + + await event.sign() + await event.publish() + + return event.id +} + +// ============================================================ +// BATCH PUBLISHING +// ============================================================ + +const publishMultipleEvents = async ( + ndk: NDK, + events: Array<{ kind: number; content: string; tags: NDKTag[] }> +) => { + const results = [] + + for (const eventData of events) { + try { + const event = new NDKEvent(ndk) + event.kind = eventData.kind + event.content = eventData.content + event.tags = eventData.tags + + await event.sign() + await event.publish() + + results.push({ success: true, eventId: event.id }) + } catch (error) { + results.push({ success: false, error }) + } + } + + return results +} + +// ============================================================ +// PUBLISH WITH CUSTOM SIGNER +// ============================================================ + +import { NDKSigner } from '@nostr-dev-kit/ndk' + +const publishWithCustomSigner = async ( + ndk: NDK, + signer: NDKSigner, + eventData: { kind: number; content: string; tags: NDKTag[] } +) => { + const event = new NDKEvent(ndk) + event.kind = eventData.kind + event.content = eventData.content + event.tags = eventData.tags + + // Sign with specific signer (not ndk.signer) + await event.sign(signer) + await event.publish() + + return event.id +} + +// ============================================================ +// ERROR HANDLING PATTERN +// ============================================================ + +const publishWithErrorHandling = async ( + ndk: NDK, + eventData: { kind: number; content: string; tags: NDKTag[] } +) => { + // Validate NDK + if (!ndk) { + throw new Error('NDK not initialized') + } + + // Validate signer + if (!ndk.signer) { + throw new Error('No active signer. Please login first.') + } + + try { + const event = new NDKEvent(ndk) + event.kind = eventData.kind + event.content = eventData.content + event.tags = eventData.tags + + // Sign + await event.sign() + + // Verify signature + if (!event.sig) { + throw new Error('Event signing failed') + } + + // Publish + await event.publish() + + // Verify event ID + if (!event.id) { + throw new Error('Event ID not generated') + } + + return { + success: true, + eventId: event.id, + pubkey: event.pubkey + } + } catch (error) { + console.error('Publishing failed:', error) + + if (error instanceof Error) { + // Handle specific error types + if (error.message.includes('relay')) { + throw new Error('Failed to publish to relays. Check connection.') + } + if (error.message.includes('sign')) { + throw new Error('Failed to sign event. Check signer.') + } + } + + throw error + } +} + +// ============================================================ +// USAGE EXAMPLE +// ============================================================ + +async function publishingExample(ndk: NDK) { + // Simple note + await publishBasicNote(ndk, 'Hello Nostr!') + + // Note with tags + await publishNoteWithTags(ndk, 'Check out this product!', { + hashtags: ['marketplace', 'nostr'], + mentions: ['pubkey123...'] + }) + + // Product listing + await publishProduct(ndk, { + slug: 'bitcoin-tshirt', + title: 'Bitcoin T-Shirt', + description: 'High quality Bitcoin t-shirt', + price: 25, + currency: 'USD', + images: ['https://example.com/image.jpg'], + category: 'clothing' + }) + + // Order + await createOrder(ndk, { + orderId: 'order-123', + sellerPubkey: 'seller-pubkey', + productRef: '30402:pubkey:bitcoin-tshirt', + quantity: 1, + totalAmount: '25.00', + currency: 'USD', + email: 'customer@example.com' + }) +} + +export { + publishBasicNote, + publishNoteWithTags, + publishProduct, + createOrder, + publishStatusUpdate, + publishMultipleEvents, + publishWithCustomSigner, + publishWithErrorHandling +} + diff --git a/.claude/skills/ndk/examples/04-querying-subscribing.ts b/.claude/skills/ndk/examples/04-querying-subscribing.ts new file mode 100644 index 00000000..ff75e97d --- /dev/null +++ b/.claude/skills/ndk/examples/04-querying-subscribing.ts @@ -0,0 +1,404 @@ +/** + * NDK Query and Subscription Patterns + * + * Examples from: src/queries/orders.tsx, src/queries/payment.tsx + */ + +import NDK, { NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk' + +// ============================================================ +// BASIC FETCH (ONE-TIME QUERY) +// ============================================================ + +const fetchNotes = async (ndk: NDK, authorPubkey: string, limit: number = 50) => { + const filter: NDKFilter = { + kinds: [1], // Text notes + authors: [authorPubkey], + limit + } + + // Fetch returns a Set + const events = await ndk.fetchEvents(filter) + + // Convert to array and sort by timestamp + const eventArray = Array.from(events).sort((a, b) => + (b.created_at || 0) - (a.created_at || 0) + ) + + return eventArray +} + +// ============================================================ +// FETCH WITH MULTIPLE FILTERS +// ============================================================ + +const fetchProductsByMultipleAuthors = async ( + ndk: NDK, + pubkeys: string[] +) => { + const filter: NDKFilter = { + kinds: [30402], // Product listings + authors: pubkeys, + limit: 100 + } + + const events = await ndk.fetchEvents(filter) + return Array.from(events) +} + +// ============================================================ +// FETCH WITH TAG FILTERS +// ============================================================ + +const fetchOrderEvents = async (ndk: NDK, orderId: string) => { + const filter: NDKFilter = { + kinds: [16, 17], // Order and payment receipt + '#order': [orderId], // Tag filter (note the # prefix) + } + + const events = await ndk.fetchEvents(filter) + return Array.from(events) +} + +// ============================================================ +// FETCH WITH TIME RANGE +// ============================================================ + +const fetchRecentEvents = async ( + ndk: NDK, + kind: number, + hoursAgo: number = 24 +) => { + const now = Math.floor(Date.now() / 1000) + const since = now - (hoursAgo * 3600) + + const filter: NDKFilter = { + kinds: [kind], + since, + until: now, + limit: 100 + } + + const events = await ndk.fetchEvents(filter) + return Array.from(events) +} + +// ============================================================ +// FETCH BY EVENT ID +// ============================================================ + +const fetchEventById = async (ndk: NDK, eventId: string) => { + const filter: NDKFilter = { + ids: [eventId] + } + + const events = await ndk.fetchEvents(filter) + + if (events.size === 0) { + return null + } + + return Array.from(events)[0] +} + +// ============================================================ +// BASIC SUBSCRIPTION (REAL-TIME) +// ============================================================ + +const subscribeToNotes = ( + ndk: NDK, + authorPubkey: string, + onEvent: (event: NDKEvent) => void +): NDKSubscription => { + const filter: NDKFilter = { + kinds: [1], + authors: [authorPubkey] + } + + const subscription = ndk.subscribe(filter, { + closeOnEose: false // Keep open for real-time updates + }) + + // Event handler + subscription.on('event', (event: NDKEvent) => { + onEvent(event) + }) + + // EOSE (End of Stored Events) handler + subscription.on('eose', () => { + console.log('✅ Received all stored events') + }) + + return subscription +} + +// ============================================================ +// SUBSCRIPTION WITH CLEANUP +// ============================================================ + +const createManagedSubscription = ( + ndk: NDK, + filter: NDKFilter, + handlers: { + onEvent: (event: NDKEvent) => void + onEose?: () => void + onClose?: () => void + } +) => { + const subscription = ndk.subscribe(filter, { closeOnEose: false }) + + subscription.on('event', handlers.onEvent) + + if (handlers.onEose) { + subscription.on('eose', handlers.onEose) + } + + if (handlers.onClose) { + subscription.on('close', handlers.onClose) + } + + // Return cleanup function + return () => { + subscription.stop() + console.log('✅ Subscription stopped') + } +} + +// ============================================================ +// MONITORING SPECIFIC EVENT +// ============================================================ + +const monitorPaymentReceipt = ( + ndk: NDK, + orderId: string, + invoiceId: string, + onPaymentReceived: (preimage: string) => void +): NDKSubscription => { + const sessionStart = Math.floor(Date.now() / 1000) + + const filter: NDKFilter = { + kinds: [17], // Payment receipt + '#order': [orderId], + '#payment-request': [invoiceId], + since: sessionStart - 30 // 30 second buffer for clock skew + } + + const subscription = ndk.subscribe(filter, { closeOnEose: false }) + + subscription.on('event', (event: NDKEvent) => { + // Verify event is recent + if (event.created_at && event.created_at < sessionStart - 30) { + console.log('⏰ Ignoring old receipt') + return + } + + // Verify it's the correct invoice + const paymentRequestTag = event.tags.find(tag => tag[0] === 'payment-request') + if (paymentRequestTag?.[1] !== invoiceId) { + return + } + + // Extract preimage + const paymentTag = event.tags.find(tag => tag[0] === 'payment') + const preimage = paymentTag?.[3] || 'external-payment' + + console.log('✅ Payment received!') + subscription.stop() + onPaymentReceived(preimage) + }) + + return subscription +} + +// ============================================================ +// REACT INTEGRATION PATTERN +// ============================================================ + +import { useEffect, useState } from 'react' + +function useOrderSubscription(ndk: NDK | null, orderId: string) { + const [events, setEvents] = useState([]) + const [eosed, setEosed] = useState(false) + + useEffect(() => { + if (!ndk || !orderId) return + + const filter: NDKFilter = { + kinds: [16, 17], + '#order': [orderId] + } + + const subscription = ndk.subscribe(filter, { closeOnEose: false }) + + subscription.on('event', (event: NDKEvent) => { + setEvents(prev => { + // Avoid duplicates + if (prev.some(e => e.id === event.id)) { + return prev + } + return [...prev, event].sort((a, b) => + (a.created_at || 0) - (b.created_at || 0) + ) + }) + }) + + subscription.on('eose', () => { + setEosed(true) + }) + + // Cleanup on unmount + return () => { + subscription.stop() + } + }, [ndk, orderId]) + + return { events, eosed } +} + +// ============================================================ +// REACT QUERY INTEGRATION +// ============================================================ + +import { useQuery, useQueryClient } from '@tanstack/react-query' + +// Query function +const fetchProducts = async (ndk: NDK, pubkey: string) => { + if (!ndk) throw new Error('NDK not initialized') + + const filter: NDKFilter = { + kinds: [30402], + authors: [pubkey] + } + + const events = await ndk.fetchEvents(filter) + return Array.from(events) +} + +// Hook with subscription for real-time updates +function useProductsWithSubscription(ndk: NDK | null, pubkey: string) { + const queryClient = useQueryClient() + + // Initial query + const query = useQuery({ + queryKey: ['products', pubkey], + queryFn: () => fetchProducts(ndk!, pubkey), + enabled: !!ndk && !!pubkey, + staleTime: 30000 + }) + + // Real-time subscription + useEffect(() => { + if (!ndk || !pubkey) return + + const filter: NDKFilter = { + kinds: [30402], + authors: [pubkey] + } + + const subscription = ndk.subscribe(filter, { closeOnEose: false }) + + subscription.on('event', () => { + // Invalidate query to trigger refetch + queryClient.invalidateQueries({ queryKey: ['products', pubkey] }) + }) + + return () => { + subscription.stop() + } + }, [ndk, pubkey, queryClient]) + + return query +} + +// ============================================================ +// ADVANCED: WAITING FOR SPECIFIC EVENT +// ============================================================ + +const waitForEvent = ( + ndk: NDK, + filter: NDKFilter, + condition: (event: NDKEvent) => boolean, + timeoutMs: number = 30000 +): Promise => { + return new Promise((resolve) => { + const subscription = ndk.subscribe(filter, { closeOnEose: false }) + + // Timeout + const timeout = setTimeout(() => { + subscription.stop() + resolve(null) + }, timeoutMs) + + // Event handler + subscription.on('event', (event: NDKEvent) => { + if (condition(event)) { + clearTimeout(timeout) + subscription.stop() + resolve(event) + } + }) + }) +} + +// Usage example +async function waitForPayment(ndk: NDK, orderId: string, invoiceId: string) { + const paymentEvent = await waitForEvent( + ndk, + { + kinds: [17], + '#order': [orderId], + since: Math.floor(Date.now() / 1000) + }, + (event) => { + const tag = event.tags.find(t => t[0] === 'payment-request') + return tag?.[1] === invoiceId + }, + 60000 // 60 second timeout + ) + + if (paymentEvent) { + console.log('✅ Payment confirmed!') + return paymentEvent + } else { + console.log('⏰ Payment timeout') + return null + } +} + +// ============================================================ +// USAGE EXAMPLES +// ============================================================ + +async function queryExample(ndk: NDK) { + // Fetch notes + const notes = await fetchNotes(ndk, 'pubkey123', 50) + console.log(`Found ${notes.length} notes`) + + // Subscribe to new notes + const cleanup = subscribeToNotes(ndk, 'pubkey123', (event) => { + console.log('New note:', event.content) + }) + + // Clean up after 60 seconds + setTimeout(cleanup, 60000) + + // Monitor payment + monitorPaymentReceipt(ndk, 'order-123', 'invoice-456', (preimage) => { + console.log('Payment received:', preimage) + }) +} + +export { + fetchNotes, + fetchProductsByMultipleAuthors, + fetchOrderEvents, + fetchRecentEvents, + fetchEventById, + subscribeToNotes, + createManagedSubscription, + monitorPaymentReceipt, + useOrderSubscription, + useProductsWithSubscription, + waitForEvent +} + diff --git a/.claude/skills/ndk/examples/05-users-profiles.ts b/.claude/skills/ndk/examples/05-users-profiles.ts new file mode 100644 index 00000000..3a9beb65 --- /dev/null +++ b/.claude/skills/ndk/examples/05-users-profiles.ts @@ -0,0 +1,423 @@ +/** + * NDK User and Profile Handling + * + * Examples from: src/queries/profiles.tsx, src/components/Profile.tsx + */ + +import NDK, { NDKUser, NDKUserProfile } from '@nostr-dev-kit/ndk' +import { nip19 } from 'nostr-tools' + +// ============================================================ +// FETCH PROFILE BY NPUB +// ============================================================ + +const fetchProfileByNpub = async (ndk: NDK, npub: string): Promise => { + try { + // Get user object from npub + const user = ndk.getUser({ npub }) + + // Fetch profile from relays + const profile = await user.fetchProfile() + + return profile + } catch (error) { + console.error('Failed to fetch profile:', error) + return null + } +} + +// ============================================================ +// FETCH PROFILE BY HEX PUBKEY +// ============================================================ + +const fetchProfileByPubkey = async (ndk: NDK, pubkey: string): Promise => { + try { + const user = ndk.getUser({ hexpubkey: pubkey }) + const profile = await user.fetchProfile() + + return profile + } catch (error) { + console.error('Failed to fetch profile:', error) + return null + } +} + +// ============================================================ +// FETCH PROFILE BY NIP-05 +// ============================================================ + +const fetchProfileByNip05 = async (ndk: NDK, nip05: string): Promise => { + try { + // Resolve NIP-05 identifier to user + const user = await ndk.getUserFromNip05(nip05) + + if (!user) { + console.log('User not found for NIP-05:', nip05) + return null + } + + // Fetch profile + const profile = await user.fetchProfile() + + return profile + } catch (error) { + console.error('Failed to fetch profile by NIP-05:', error) + return null + } +} + +// ============================================================ +// FETCH PROFILE BY ANY IDENTIFIER +// ============================================================ + +const fetchProfileByIdentifier = async ( + ndk: NDK, + identifier: string +): Promise<{ profile: NDKUserProfile | null; user: NDKUser | null }> => { + try { + // Check if it's a NIP-05 (contains @) + if (identifier.includes('@')) { + const user = await ndk.getUserFromNip05(identifier) + if (!user) return { profile: null, user: null } + + const profile = await user.fetchProfile() + return { profile, user } + } + + // Check if it's an npub + if (identifier.startsWith('npub')) { + const user = ndk.getUser({ npub: identifier }) + const profile = await user.fetchProfile() + return { profile, user } + } + + // Assume it's a hex pubkey + const user = ndk.getUser({ hexpubkey: identifier }) + const profile = await user.fetchProfile() + return { profile, user } + } catch (error) { + console.error('Failed to fetch profile:', error) + return { profile: null, user: null } + } +} + +// ============================================================ +// GET CURRENT USER +// ============================================================ + +const getCurrentUser = async (ndk: NDK): Promise => { + if (!ndk.signer) { + console.log('No signer set') + return null + } + + try { + const user = await ndk.signer.user() + return user + } catch (error) { + console.error('Failed to get current user:', error) + return null + } +} + +// ============================================================ +// PROFILE DATA STRUCTURE +// ============================================================ + +interface ProfileData { + // Standard fields + name?: string + displayName?: string + display_name?: string + picture?: string + image?: string + banner?: string + about?: string + + // Contact + nip05?: string + lud06?: string // LNURL + lud16?: string // Lightning address + + // Social + website?: string + + // Raw data + [key: string]: any +} + +// ============================================================ +// EXTRACT PROFILE INFO +// ============================================================ + +const extractProfileInfo = (profile: NDKUserProfile | null) => { + if (!profile) { + return { + displayName: 'Anonymous', + avatar: null, + bio: null, + lightningAddress: null, + nip05: null + } + } + + return { + displayName: profile.displayName || profile.display_name || profile.name || 'Anonymous', + avatar: profile.picture || profile.image || null, + banner: profile.banner || null, + bio: profile.about || null, + lightningAddress: profile.lud16 || profile.lud06 || null, + nip05: profile.nip05 || null, + website: profile.website || null + } +} + +// ============================================================ +// UPDATE PROFILE +// ============================================================ + +import { NDKEvent } from '@nostr-dev-kit/ndk' + +const updateProfile = async (ndk: NDK, profileData: Partial) => { + if (!ndk.signer) { + throw new Error('No signer available') + } + + // Get current profile + const currentUser = await ndk.signer.user() + const currentProfile = await currentUser.fetchProfile() + + // Merge with new data + const updatedProfile = { + ...currentProfile, + ...profileData + } + + // Create kind 0 (metadata) event + const event = new NDKEvent(ndk) + event.kind = 0 + event.content = JSON.stringify(updatedProfile) + event.tags = [] + + await event.sign() + await event.publish() + + console.log('✅ Profile updated') + return event.id +} + +// ============================================================ +// BATCH FETCH PROFILES +// ============================================================ + +const fetchMultipleProfiles = async ( + ndk: NDK, + pubkeys: string[] +): Promise> => { + const profiles = new Map() + + // Fetch all profiles in parallel + await Promise.all( + pubkeys.map(async (pubkey) => { + try { + const user = ndk.getUser({ hexpubkey: pubkey }) + const profile = await user.fetchProfile() + profiles.set(pubkey, profile) + } catch (error) { + console.error(`Failed to fetch profile for ${pubkey}:`, error) + profiles.set(pubkey, null) + } + }) + ) + + return profiles +} + +// ============================================================ +// CONVERT BETWEEN FORMATS +// ============================================================ + +const convertPubkeyFormats = (identifier: string) => { + try { + // If it's npub, convert to hex + if (identifier.startsWith('npub')) { + const decoded = nip19.decode(identifier) + if (decoded.type === 'npub') { + return { + hex: decoded.data as string, + npub: identifier + } + } + } + + // If it's hex, convert to npub + if (/^[0-9a-f]{64}$/.test(identifier)) { + return { + hex: identifier, + npub: nip19.npubEncode(identifier) + } + } + + throw new Error('Invalid pubkey format') + } catch (error) { + console.error('Format conversion failed:', error) + return null + } +} + +// ============================================================ +// REACT HOOK FOR PROFILE +// ============================================================ + +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' + +function useProfile(ndk: NDK | null, npub: string | undefined) { + return useQuery({ + queryKey: ['profile', npub], + queryFn: async () => { + if (!ndk || !npub) throw new Error('NDK or npub missing') + return await fetchProfileByNpub(ndk, npub) + }, + enabled: !!ndk && !!npub, + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 30 * 60 * 1000 // 30 minutes + }) +} + +// ============================================================ +// REACT COMPONENT EXAMPLE +// ============================================================ + +interface ProfileDisplayProps { + ndk: NDK + pubkey: string +} + +function ProfileDisplay({ ndk, pubkey }: ProfileDisplayProps) { + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const loadProfile = async () => { + setLoading(true) + try { + const user = ndk.getUser({ hexpubkey: pubkey }) + const fetchedProfile = await user.fetchProfile() + setProfile(fetchedProfile) + } catch (error) { + console.error('Failed to load profile:', error) + } finally { + setLoading(false) + } + } + + loadProfile() + }, [ndk, pubkey]) + + if (loading) { + return
Loading profile...
+ } + + const info = extractProfileInfo(profile) + + return ( +
+ {info.avatar && {info.displayName}} +

{info.displayName}

+ {info.bio &&

{info.bio}

} + {info.nip05 && ✓ {info.nip05}} + {info.lightningAddress && ⚡ {info.lightningAddress}} +
+ ) +} + +// ============================================================ +// FOLLOW/UNFOLLOW USER +// ============================================================ + +const followUser = async (ndk: NDK, pubkeyToFollow: string) => { + if (!ndk.signer) { + throw new Error('No signer available') + } + + // Fetch current contact list (kind 3) + const currentUser = await ndk.signer.user() + const contactListFilter = { + kinds: [3], + authors: [currentUser.pubkey] + } + + const existingEvents = await ndk.fetchEvents(contactListFilter) + const existingContactList = existingEvents.size > 0 + ? Array.from(existingEvents)[0] + : null + + // Get existing p tags + const existingPTags = existingContactList + ? existingContactList.tags.filter(tag => tag[0] === 'p') + : [] + + // Check if already following + const alreadyFollowing = existingPTags.some(tag => tag[1] === pubkeyToFollow) + if (alreadyFollowing) { + console.log('Already following this user') + return + } + + // Create new contact list with added user + const event = new NDKEvent(ndk) + event.kind = 3 + event.content = existingContactList?.content || '' + event.tags = [ + ...existingPTags, + ['p', pubkeyToFollow] + ] + + await event.sign() + await event.publish() + + console.log('✅ Now following user') +} + +// ============================================================ +// USAGE EXAMPLE +// ============================================================ + +async function profileExample(ndk: NDK) { + // Fetch by different identifiers + const profile1 = await fetchProfileByNpub(ndk, 'npub1...') + const profile2 = await fetchProfileByNip05(ndk, 'user@domain.com') + const profile3 = await fetchProfileByPubkey(ndk, 'hex pubkey...') + + // Extract display info + const info = extractProfileInfo(profile1) + console.log('Display name:', info.displayName) + console.log('Avatar:', info.avatar) + + // Update own profile + await updateProfile(ndk, { + name: 'My Name', + about: 'My bio', + picture: 'https://example.com/avatar.jpg', + lud16: 'me@getalby.com' + }) + + // Follow someone + await followUser(ndk, 'pubkey to follow') +} + +export { + fetchProfileByNpub, + fetchProfileByPubkey, + fetchProfileByNip05, + fetchProfileByIdentifier, + getCurrentUser, + extractProfileInfo, + updateProfile, + fetchMultipleProfiles, + convertPubkeyFormats, + useProfile, + followUser +} + diff --git a/.claude/skills/ndk/examples/README.md b/.claude/skills/ndk/examples/README.md new file mode 100644 index 00000000..25b990a3 --- /dev/null +++ b/.claude/skills/ndk/examples/README.md @@ -0,0 +1,94 @@ +# NDK Examples Index + +Complete code examples extracted from the Plebeian Market production codebase. + +## Available Examples + +### 01-initialization.ts +- Basic NDK initialization +- Multiple NDK instances (main + zap relays) +- Connection with timeout protection +- Connection status checking +- Full initialization flow with error handling + +### 02-authentication.ts +- NIP-07 browser extension login +- Private key signer +- NIP-46 remote signer (Bunker) +- Auto-login from localStorage +- Saving auth credentials +- Logout functionality +- Getting current user + +### 03-publishing-events.ts +- Basic note publishing +- Events with tags (mentions, hashtags, replies) +- Product listings (parameterized replaceable events) +- Order creation events +- Status update events +- Batch publishing +- Custom signer usage +- Comprehensive error handling + +### 04-querying-subscribing.ts +- Basic fetch queries +- Multiple author queries +- Tag filtering +- Time range filtering +- Event ID lookup +- Real-time subscriptions +- Subscription cleanup patterns +- React integration hooks +- React Query integration +- Waiting for specific events +- Payment monitoring + +### 05-users-profiles.ts +- Fetch profile by npub +- Fetch profile by hex pubkey +- Fetch profile by NIP-05 +- Universal identifier lookup +- Get current user +- Extract profile information +- Update user profile +- Batch fetch multiple profiles +- Convert between pubkey formats (hex/npub) +- React hooks for profiles +- Follow/unfollow users + +## Usage + +Each file contains: +- Fully typed TypeScript code +- JSDoc comments explaining the pattern +- Error handling examples +- Integration patterns with React/TanStack Query +- Real-world usage examples + +All examples are based on actual production code from the Plebeian Market application. + +## Running Examples + +```typescript +import { initializeNDK } from './01-initialization' +import { loginWithExtension } from './02-authentication' +import { publishBasicNote } from './03-publishing-events' + +// Initialize NDK +const { ndk, isConnected } = await initializeNDK() + +if (isConnected) { + // Authenticate + const { user } = await loginWithExtension(ndk) + + // Publish + await publishBasicNote(ndk, 'Hello Nostr!') +} +``` + +## Additional Resources + +- See `../ndk-skill.md` for detailed documentation +- See `../quick-reference.md` for quick lookup +- Check the main codebase for more complex patterns + diff --git a/.claude/skills/ndk/ndk-skill.md b/.claude/skills/ndk/ndk-skill.md new file mode 100644 index 00000000..680fb433 --- /dev/null +++ b/.claude/skills/ndk/ndk-skill.md @@ -0,0 +1,701 @@ +# NDK (Nostr Development Kit) - Claude Skill Reference + +## Overview + +NDK is the primary Nostr development kit with outbox-model support, designed for building Nostr applications with TypeScript/JavaScript. This reference is based on analyzing production usage in the Plebeian Market codebase. + +## Core Concepts + +### 1. NDK Initialization + +**Basic Pattern:** +```typescript +import NDK from '@nostr-dev-kit/ndk' + +// Simple initialization +const ndk = new NDK({ + explicitRelayUrls: ['wss://relay.damus.io', 'wss://relay.nostr.band'] +}) + +await ndk.connect() +``` + +**Store-based Pattern (Production):** +```typescript +// From src/lib/stores/ndk.ts +const ndk = new NDK({ + explicitRelayUrls: relays || defaultRelaysUrls, +}) + +// Separate NDK for zaps on specialized relays +const zapNdk = new NDK({ + explicitRelayUrls: ZAP_RELAYS, +}) + +// Connect with timeout protection +const connectPromise = ndk.connect() +const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection timeout')), timeoutMs) +) +await Promise.race([connectPromise, timeoutPromise]) +``` + +### 2. Authentication & Signers + +NDK supports multiple signer types for different authentication methods: + +#### NIP-07 (Browser Extension) +```typescript +import { NDKNip07Signer } from '@nostr-dev-kit/ndk' + +const signer = new NDKNip07Signer() +await signer.blockUntilReady() +ndk.signer = signer + +const user = await signer.user() +``` + +#### Private Key Signer +```typescript +import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk' + +const signer = new NDKPrivateKeySigner(privateKeyHex) +await signer.blockUntilReady() +ndk.signer = signer + +const user = await signer.user() +``` + +#### NIP-46 (Remote Signer / Bunker) +```typescript +import { NDKNip46Signer } from '@nostr-dev-kit/ndk' + +const localSigner = new NDKPrivateKeySigner(localPrivateKey) +const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner) +await remoteSigner.blockUntilReady() +ndk.signer = remoteSigner + +const user = await remoteSigner.user() +``` + +**Key Points:** +- Always call `blockUntilReady()` before using a signer +- Store signer reference in your state management +- Set `ndk.signer` to enable signing operations +- Use `await signer.user()` to get the authenticated user + +### 3. Event Creation & Publishing + +#### Basic Event Pattern +```typescript +import { NDKEvent } from '@nostr-dev-kit/ndk' + +// Create event +const event = new NDKEvent(ndk) +event.kind = 1 // Kind 1 = text note +event.content = "Hello Nostr!" +event.tags = [ + ['t', 'nostr'], + ['p', recipientPubkey] +] + +// Sign and publish +await event.sign() // Uses ndk.signer automatically +await event.publish() + +// Get event ID after signing +console.log(event.id) +``` + +#### Production Pattern with Error Handling +```typescript +// From src/publish/orders.tsx +const event = new NDKEvent(ndk) +event.kind = ORDER_PROCESS_KIND +event.content = orderNotes || '' +event.tags = [ + ['p', sellerPubkey], + ['subject', `Order for ${productName}`], + ['type', 'order-creation'], + ['order', orderId], + ['amount', totalAmount], + ['item', productRef, quantity.toString()], +] + +// Optional tags +if (shippingRef) { + event.tags.push(['shipping', shippingRef]) +} + +try { + await event.sign(signer) // Can pass explicit signer + await event.publish() + return event.id +} catch (error) { + console.error('Failed to publish event:', error) + throw error +} +``` + +**Key Points:** +- Create event with `new NDKEvent(ndk)` +- Set `kind`, `content`, and `tags` properties +- Optional: Set `created_at` timestamp (defaults to now) +- Call `await event.sign()` before publishing +- Call `await event.publish()` to broadcast to relays +- Access `event.id` after signing for the event hash + +### 4. Querying Events with Filters + +#### fetchEvents() - One-time Fetch +```typescript +import { NDKFilter } from '@nostr-dev-kit/ndk' + +// Simple filter +const filter: NDKFilter = { + kinds: [30402], // Product listings + authors: [merchantPubkey], + limit: 50 +} + +const events = await ndk.fetchEvents(filter) +// Returns Set + +// Convert to array and process +const eventArray = Array.from(events) +const sortedEvents = eventArray.sort((a, b) => + (b.created_at || 0) - (a.created_at || 0) +) +``` + +#### Advanced Filters +```typescript +// Multiple kinds +const filter: NDKFilter = { + kinds: [16, 17], // Orders and payment receipts + '#order': [orderId], // Tag filter (# prefix) + since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours + limit: 100 +} + +// Event ID lookup +const filter: NDKFilter = { + ids: [eventIdHex], +} + +// Tag filtering +const filter: NDKFilter = { + kinds: [1], + '#p': [pubkey], // Events mentioning pubkey + '#t': ['nostr'], // Events with hashtag 'nostr' +} +``` + +### 5. Subscriptions (Real-time) + +#### Basic Subscription +```typescript +// From src/queries/blacklist.tsx +const filter = { + kinds: [10000], + authors: [appPubkey], +} + +const subscription = ndk.subscribe(filter, { + closeOnEose: false, // Keep open for real-time updates +}) + +subscription.on('event', (event: NDKEvent) => { + console.log('New event received:', event) + // Process event +}) + +subscription.on('eose', () => { + console.log('End of stored events') +}) + +// Cleanup +subscription.stop() +``` + +#### Production Pattern with React Query +```typescript +// From src/queries/orders.tsx +useEffect(() => { + if (!orderId || !ndk) return + + const filter = { + kinds: [ORDER_PROCESS_KIND, PAYMENT_RECEIPT_KIND], + '#order': [orderId], + } + + const subscription = ndk.subscribe(filter, { + closeOnEose: false, + }) + + subscription.on('event', (newEvent) => { + // Invalidate React Query cache to trigger refetch + queryClient.invalidateQueries({ + queryKey: orderKeys.details(orderId) + }) + }) + + // Cleanup on unmount + return () => { + subscription.stop() + } +}, [orderId, ndk, queryClient]) +``` + +#### Monitoring Specific Events +```typescript +// From src/queries/payment.tsx - Payment receipt monitoring +const receiptFilter = { + kinds: [17], // Payment receipts + '#order': [orderId], + '#payment-request': [invoiceId], + since: sessionStartTime - 30, // Clock skew buffer +} + +const subscription = ndk.subscribe(receiptFilter, { + closeOnEose: false, +}) + +subscription.on('event', (receiptEvent: NDKEvent) => { + // Verify this is the correct invoice + const paymentRequestTag = receiptEvent.tags.find( + tag => tag[0] === 'payment-request' + ) + + if (paymentRequestTag?.[1] === invoiceId) { + const paymentTag = receiptEvent.tags.find(tag => tag[0] === 'payment') + const preimage = paymentTag?.[3] || 'external-payment' + + // Stop subscription after finding payment + subscription.stop() + handlePaymentReceived(preimage) + } +}) +``` + +**Key Subscription Patterns:** +- Use `closeOnEose: false` for real-time monitoring +- Use `closeOnEose: true` for one-time historical fetch +- Always call `subscription.stop()` in cleanup +- Listen to both `'event'` and `'eose'` events +- Filter events in the handler for specific conditions +- Integrate with React Query for reactive UI updates + +### 6. User & Profile Handling + +#### Fetching User Profiles +```typescript +// From src/queries/profiles.tsx + +// By npub +const user = ndk.getUser({ npub }) +const profile = await user.fetchProfile() +// Returns NDKUserProfile with name, picture, about, etc. + +// By hex pubkey +const user = ndk.getUser({ hexpubkey: pubkey }) +const profile = await user.fetchProfile() + +// By NIP-05 identifier +const user = await ndk.getUserFromNip05('user@domain.com') +if (user) { + const profile = await user.fetchProfile() +} + +// Profile fields +const name = profile?.name || profile?.displayName +const avatar = profile?.picture || profile?.image +const bio = profile?.about +const nip05 = profile?.nip05 +const lud16 = profile?.lud16 // Lightning address +``` + +#### Getting Current User +```typescript +// Active user (authenticated) +const user = ndk.activeUser + +// From signer +const user = await ndk.signer?.user() + +// User properties +const pubkey = user.pubkey // Hex format +const npub = user.npub // NIP-19 encoded +``` + +### 7. NDK Event Object + +#### Essential Properties +```typescript +interface NDKEvent { + id: string // Event hash (after signing) + kind: number // Event kind + content: string // Event content + tags: NDKTag[] // Array of tag arrays + created_at?: number // Unix timestamp + pubkey?: string // Author pubkey (after signing) + sig?: string // Signature (after signing) + + // Methods + sign(signer?: NDKSigner): Promise + publish(): Promise + tagValue(tagName: string): string | undefined +} + +type NDKTag = string[] // e.g., ['p', pubkey, relay, petname] +``` + +#### Tag Helpers +```typescript +// Get first value of a tag +const orderId = event.tagValue('order') +const recipientPubkey = event.tagValue('p') + +// Find specific tag +const paymentTag = event.tags.find(tag => tag[0] === 'payment') +const preimage = paymentTag?.[3] + +// Get all tags of a type +const pTags = event.tags.filter(tag => tag[0] === 'p') +const allPubkeys = pTags.map(tag => tag[1]) + +// Common tag patterns +event.tags.push(['p', pubkey]) // Mention +event.tags.push(['e', eventId]) // Reference event +event.tags.push(['t', 'nostr']) // Hashtag +event.tags.push(['d', identifier]) // Replaceable event ID +event.tags.push(['a', '30402:pubkey:d-tag']) // Addressable event reference +``` + +### 8. Parameterized Replaceable Events (NIP-33) + +Used for products, collections, profiles that need updates: + +```typescript +// Product listing (kind 30402) +const event = new NDKEvent(ndk) +event.kind = 30402 +event.content = JSON.stringify(productDetails) +event.tags = [ + ['d', productSlug], // Unique identifier + ['title', productName], + ['price', price, currency], + ['image', imageUrl], + ['shipping', shippingRef], +] + +await event.sign() +await event.publish() + +// Querying replaceable events +const filter = { + kinds: [30402], + authors: [merchantPubkey], + '#d': [productSlug], // Specific product +} + +const events = await ndk.fetchEvents(filter) +// Returns only the latest version due to replaceable nature +``` + +### 9. Relay Management + +#### Getting Relay Status +```typescript +// From src/lib/stores/ndk.ts +const connectedRelays = Array.from(ndk.pool?.relays.values() || []) + .filter(relay => relay.status === 1) // 1 = connected + .map(relay => relay.url) + +const outboxRelays = Array.from(ndk.outboxPool?.relays.values() || []) +``` + +#### Adding Relays +```typescript +// Add explicit relays +ndk.addExplicitRelay('wss://relay.example.com') + +// Multiple relays +const relays = ['wss://relay1.com', 'wss://relay2.com'] +relays.forEach(url => ndk.addExplicitRelay(url)) +``` + +### 10. Common Patterns & Best Practices + +#### Null Safety +```typescript +// Always check NDK initialization +const ndk = ndkActions.getNDK() +if (!ndk) throw new Error('NDK not initialized') + +// Check signer before operations requiring auth +const signer = ndk.signer +if (!signer) throw new Error('No active signer') + +// Check user authentication +const user = ndk.activeUser +if (!user) throw new Error('Not authenticated') +``` + +#### Error Handling +```typescript +try { + const events = await ndk.fetchEvents(filter) + if (events.size === 0) { + return null // No results found + } + return Array.from(events) +} catch (error) { + console.error('Failed to fetch events:', error) + throw new Error('Could not fetch data from relays') +} +``` + +#### Connection Lifecycle +```typescript +// Initialize once at app startup +const ndk = new NDK({ explicitRelayUrls: relays }) + +// Connect with timeout +await Promise.race([ + ndk.connect(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 10000) + ) +]) + +// Check connection status +const isConnected = ndk.pool?.connectedRelays().length > 0 + +// Reconnect if needed +if (!isConnected) { + await ndk.connect() +} +``` + +#### Subscription Cleanup +```typescript +// In React components +useEffect(() => { + if (!ndk) return + + const sub = ndk.subscribe(filter, { closeOnEose: false }) + + sub.on('event', handleEvent) + sub.on('eose', handleEose) + + // Critical: cleanup on unmount + return () => { + sub.stop() + } +}, [dependencies]) +``` + +#### Event Validation +```typescript +// Check required fields before processing +if (!event.pubkey) { + console.error('Event missing pubkey') + return +} + +if (!event.created_at) { + console.error('Event missing timestamp') + return +} + +// Verify event age +const now = Math.floor(Date.now() / 1000) +const eventAge = now - (event.created_at || 0) +if (eventAge > 86400) { // Older than 24 hours + console.log('Event is old, skipping') + return +} + +// Validate specific tags exist +const orderId = event.tagValue('order') +if (!orderId) { + console.error('Order event missing order ID') + return +} +``` + +### 11. Common Event Kinds + +```typescript +// NIP-01: Basic Events +const KIND_METADATA = 0 // User profile +const KIND_TEXT_NOTE = 1 // Short text note +const KIND_RECOMMEND_RELAY = 2 // Relay recommendation + +// NIP-04: Encrypted Direct Messages +const KIND_ENCRYPTED_DM = 4 + +// NIP-25: Reactions +const KIND_REACTION = 7 + +// NIP-51: Lists +const KIND_MUTE_LIST = 10000 +const KIND_PIN_LIST = 10001 +const KIND_RELAY_LIST = 10002 + +// NIP-57: Lightning Zaps +const KIND_ZAP_REQUEST = 9734 +const KIND_ZAP_RECEIPT = 9735 + +// Marketplace (Plebeian/Gamma spec) +const ORDER_PROCESS_KIND = 16 // Order processing +const PAYMENT_RECEIPT_KIND = 17 // Payment receipts +const DIRECT_MESSAGE_KIND = 14 // Direct messages +const ORDER_GENERAL_KIND = 27 // General order events +const SHIPPING_KIND = 30405 // Shipping options +const PRODUCT_KIND = 30402 // Product listings +const COLLECTION_KIND = 30401 // Product collections +const REVIEW_KIND = 30407 // Product reviews + +// Application Handlers +const APP_HANDLER_KIND = 31990 // NIP-89 app handlers +``` + +## Integration with TanStack Query + +NDK works excellently with TanStack Query for reactive data fetching: + +### Query Functions +```typescript +// From src/queries/products.tsx +export const fetchProductsByPubkey = async (pubkey: string) => { + const ndk = ndkActions.getNDK() + if (!ndk) throw new Error('NDK not initialized') + + const filter: NDKFilter = { + kinds: [30402], + authors: [pubkey], + } + + const events = await ndk.fetchEvents(filter) + return Array.from(events).map(parseProductEvent) +} + +export const useProductsByPubkey = (pubkey: string) => { + return useQuery({ + queryKey: productKeys.byAuthor(pubkey), + queryFn: () => fetchProductsByPubkey(pubkey), + enabled: !!pubkey, + staleTime: 30000, + }) +} +``` + +### Combining Queries with Subscriptions +```typescript +// Query for initial data +const { data: order, refetch } = useQuery({ + queryKey: orderKeys.details(orderId), + queryFn: () => fetchOrderById(orderId), + enabled: !!orderId, +}) + +// Subscription for real-time updates +useEffect(() => { + if (!orderId || !ndk) return + + const sub = ndk.subscribe( + { kinds: [16, 17], '#order': [orderId] }, + { closeOnEose: false } + ) + + sub.on('event', () => { + // Invalidate query to trigger refetch + queryClient.invalidateQueries({ + queryKey: orderKeys.details(orderId) + }) + }) + + return () => sub.stop() +}, [orderId, ndk, queryClient]) +``` + +## Troubleshooting + +### Events Not Received +- Check relay connections: `ndk.pool?.connectedRelays()` +- Verify filter syntax (especially tag filters with `#` prefix) +- Check event timestamps match filter's `since`/`until` +- Ensure `closeOnEose: false` for real-time subscriptions + +### Signing Errors +- Verify signer is initialized: `await signer.blockUntilReady()` +- Check signer is set: `ndk.signer !== undefined` +- For NIP-07, ensure browser extension is installed and enabled +- For NIP-46, verify bunker URL and local signer are correct + +### Connection Timeouts +- Implement connection timeout pattern shown above +- Try connecting to fewer, more reliable relays initially +- Use fallback relays in production + +### Duplicate Events +- NDK deduplicates by event ID automatically +- For subscriptions, track processed event IDs if needed +- Use replaceable events (kinds 10000-19999, 30000-39999) when appropriate + +## Performance Optimization + +### Batching Queries +```typescript +// Instead of multiple fetchEvents calls +const [products, orders, profiles] = await Promise.all([ + ndk.fetchEvents(productFilter), + ndk.fetchEvents(orderFilter), + ndk.fetchEvents(profileFilter), +]) +``` + +### Limiting Results +```typescript +const filter = { + kinds: [1], + authors: [pubkey], + limit: 50, // Limit results + since: recentTimestamp, // Only recent events +} +``` + +### Caching with React Query +```typescript +export const useProfile = (npub: string) => { + return useQuery({ + queryKey: profileKeys.byNpub(npub), + queryFn: () => fetchProfileByNpub(npub), + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 30 * 60 * 1000, // 30 minutes + enabled: !!npub, + }) +} +``` + +## References + +- **NDK GitHub**: https://github.com/nostr-dev-kit/ndk +- **NDK Documentation**: https://ndk.fyi +- **Nostr NIPs**: https://github.com/nostr-protocol/nips +- **Production Example**: Plebeian Market codebase + +## Key Files in This Codebase + +- `src/lib/stores/ndk.ts` - NDK store and initialization +- `src/lib/stores/auth.ts` - Authentication with NDK signers +- `src/queries/*.tsx` - Query patterns with NDK +- `src/publish/*.tsx` - Event publishing patterns +- `scripts/gen_*.ts` - Event creation examples + +--- + +*This reference is based on NDK version used in production and real-world patterns from the Plebeian Market application.* + diff --git a/.claude/skills/ndk/quick-reference.md b/.claude/skills/ndk/quick-reference.md new file mode 100644 index 00000000..3af6cc27 --- /dev/null +++ b/.claude/skills/ndk/quick-reference.md @@ -0,0 +1,351 @@ +# NDK Quick Reference + +Fast lookup guide for common NDK tasks. + +## Quick Start + +```typescript +import NDK from '@nostr-dev-kit/ndk' + +const ndk = new NDK({ explicitRelayUrls: ['wss://relay.damus.io'] }) +await ndk.connect() +``` + +## Authentication + +### Browser Extension (NIP-07) +```typescript +import { NDKNip07Signer } from '@nostr-dev-kit/ndk' +const signer = new NDKNip07Signer() +await signer.blockUntilReady() +ndk.signer = signer +``` + +### Private Key +```typescript +import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk' +const signer = new NDKPrivateKeySigner(privateKeyHex) +await signer.blockUntilReady() +ndk.signer = signer +``` + +### Remote Signer (NIP-46) +```typescript +import { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk' +const localSigner = new NDKPrivateKeySigner() +const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner) +await remoteSigner.blockUntilReady() +ndk.signer = remoteSigner +``` + +## Publish Event + +```typescript +import { NDKEvent } from '@nostr-dev-kit/ndk' + +const event = new NDKEvent(ndk) +event.kind = 1 +event.content = "Hello Nostr!" +event.tags = [['t', 'nostr']] + +await event.sign() +await event.publish() +``` + +## Query Events (One-time) + +```typescript +const events = await ndk.fetchEvents({ + kinds: [1], + authors: [pubkey], + limit: 50 +}) + +// Convert Set to Array +const eventArray = Array.from(events) +``` + +## Subscribe (Real-time) + +```typescript +const sub = ndk.subscribe( + { kinds: [1], authors: [pubkey] }, + { closeOnEose: false } +) + +sub.on('event', (event) => { + console.log('New event:', event.content) +}) + +// Cleanup +sub.stop() +``` + +## Get User Profile + +```typescript +// By npub +const user = ndk.getUser({ npub }) +const profile = await user.fetchProfile() + +// By hex pubkey +const user = ndk.getUser({ hexpubkey: pubkey }) +const profile = await user.fetchProfile() + +// By NIP-05 +const user = await ndk.getUserFromNip05('user@domain.com') +const profile = await user?.fetchProfile() +``` + +## Common Filters + +```typescript +// By author +{ kinds: [1], authors: [pubkey] } + +// By tag +{ kinds: [1], '#p': [pubkey] } +{ kinds: [30402], '#d': [productSlug] } + +// By time +{ + kinds: [1], + since: Math.floor(Date.now() / 1000) - 86400, // Last 24h + until: Math.floor(Date.now() / 1000) +} + +// By event ID +{ ids: [eventId] } + +// Multiple conditions +{ + kinds: [16, 17], + '#order': [orderId], + since: timestamp, + limit: 100 +} +``` + +## Tag Helpers + +```typescript +// Get first tag value +const orderId = event.tagValue('order') + +// Find specific tag +const tag = event.tags.find(t => t[0] === 'payment') +const value = tag?.[1] + +// Get all of one type +const pTags = event.tags.filter(t => t[0] === 'p') + +// Common tag formats +['p', pubkey] // Mention +['e', eventId] // Event reference +['t', 'nostr'] // Hashtag +['d', identifier] // Replaceable ID +['a', '30402:pubkey:d-tag'] // Addressable reference +``` + +## Error Handling Pattern + +```typescript +const ndk = ndkActions.getNDK() +if (!ndk) throw new Error('NDK not initialized') + +const signer = ndk.signer +if (!signer) throw new Error('No active signer') + +try { + await event.publish() +} catch (error) { + console.error('Publish failed:', error) + throw error +} +``` + +## React Integration + +```typescript +// Query function +export const fetchProducts = async (pubkey: string) => { + const ndk = ndkActions.getNDK() + if (!ndk) throw new Error('NDK not initialized') + + const events = await ndk.fetchEvents({ + kinds: [30402], + authors: [pubkey] + }) + + return Array.from(events) +} + +// React Query hook +export const useProducts = (pubkey: string) => { + return useQuery({ + queryKey: ['products', pubkey], + queryFn: () => fetchProducts(pubkey), + enabled: !!pubkey, + }) +} + +// Subscription in useEffect +useEffect(() => { + if (!ndk || !orderId) return + + const sub = ndk.subscribe( + { kinds: [16], '#order': [orderId] }, + { closeOnEose: false } + ) + + sub.on('event', () => { + queryClient.invalidateQueries(['order', orderId]) + }) + + return () => sub.stop() +}, [ndk, orderId, queryClient]) +``` + +## Common Event Kinds + +```typescript +0 // Metadata (profile) +1 // Text note +4 // Encrypted DM (NIP-04) +7 // Reaction +9735 // Zap receipt +10000 // Mute list +10002 // Relay list +30402 // Product listing (Marketplace) +31990 // App handler (NIP-89) +``` + +## Relay Management + +```typescript +// Check connection +const connected = ndk.pool?.connectedRelays().length > 0 + +// Get connected relays +const relays = Array.from(ndk.pool?.relays.values() || []) + .filter(r => r.status === 1) + +// Add relay +ndk.addExplicitRelay('wss://relay.example.com') +``` + +## Connection with Timeout + +```typescript +const connectWithTimeout = async (timeoutMs = 10000) => { + const connectPromise = ndk.connect() + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), timeoutMs) + ) + + await Promise.race([connectPromise, timeoutPromise]) +} +``` + +## Current User + +```typescript +// Active user +const user = ndk.activeUser + +// From signer +const user = await ndk.signer?.user() + +// User info +const pubkey = user.pubkey // hex +const npub = user.npub // NIP-19 +``` + +## Parameterized Replaceable Events + +```typescript +// Create +const event = new NDKEvent(ndk) +event.kind = 30402 +event.content = JSON.stringify(data) +event.tags = [ + ['d', uniqueIdentifier], // Required for replaceable + ['title', 'Product Name'], +] + +await event.sign() +await event.publish() + +// Query (returns latest only) +const events = await ndk.fetchEvents({ + kinds: [30402], + authors: [pubkey], + '#d': [identifier] +}) +``` + +## Validation Checks + +```typescript +// Event age check +const now = Math.floor(Date.now() / 1000) +const age = now - (event.created_at || 0) +if (age > 86400) console.log('Event older than 24h') + +// Required fields +if (!event.pubkey || !event.created_at || !event.sig) { + throw new Error('Invalid event') +} + +// Tag existence +const orderId = event.tagValue('order') +if (!orderId) throw new Error('Missing order tag') +``` + +## Performance Tips + +```typescript +// Batch queries +const [products, orders] = await Promise.all([ + ndk.fetchEvents(productFilter), + ndk.fetchEvents(orderFilter) +]) + +// Limit results +const filter = { + kinds: [1], + limit: 50, + since: recentTimestamp +} + +// Cache with React Query +const { data } = useQuery({ + queryKey: ['profile', npub], + queryFn: () => fetchProfile(npub), + staleTime: 5 * 60 * 1000, // 5 min +}) +``` + +## Debugging + +```typescript +// Check NDK state +console.log('Connected:', ndk.pool?.connectedRelays()) +console.log('Signer:', ndk.signer) +console.log('Active user:', ndk.activeUser) + +// Event inspection +console.log('Event ID:', event.id) +console.log('Tags:', event.tags) +console.log('Content:', event.content) +console.log('Author:', event.pubkey) + +// Subscription events +sub.on('event', e => console.log('Event:', e)) +sub.on('eose', () => console.log('End of stored events')) +``` + +--- + +For detailed explanations and advanced patterns, see `ndk-skill.md`. + diff --git a/.claude/skills/ndk/troubleshooting.md b/.claude/skills/ndk/troubleshooting.md new file mode 100644 index 00000000..ac98fec2 --- /dev/null +++ b/.claude/skills/ndk/troubleshooting.md @@ -0,0 +1,530 @@ +# NDK Common Patterns & Troubleshooting + +Quick reference for common patterns and solutions to frequent NDK issues. + +## Common Patterns + +### Store-Based NDK Management + +```typescript +// Store pattern (recommended for React apps) +import { Store } from '@tanstack/store' + +interface NDKState { + ndk: NDK | null + isConnected: boolean + signer?: NDKSigner +} + +const ndkStore = new Store({ + ndk: null, + isConnected: false +}) + +export const ndkActions = { + initialize: () => { + const ndk = new NDK({ explicitRelayUrls: relays }) + ndkStore.setState({ ndk }) + return ndk + }, + + getNDK: () => ndkStore.state.ndk, + + setSigner: (signer: NDKSigner) => { + const ndk = ndkStore.state.ndk + if (ndk) { + ndk.signer = signer + ndkStore.setState({ signer }) + } + } +} +``` + +### Query + Subscription Pattern + +```typescript +// Initial data load + real-time updates +function useOrdersWithRealtime(orderId: string) { + const queryClient = useQueryClient() + const ndk = ndkActions.getNDK() + + // Fetch initial data + const query = useQuery({ + queryKey: ['orders', orderId], + queryFn: () => fetchOrders(orderId), + }) + + // Subscribe to updates + useEffect(() => { + if (!ndk || !orderId) return + + const sub = ndk.subscribe( + { kinds: [16], '#order': [orderId] }, + { closeOnEose: false } + ) + + sub.on('event', () => { + queryClient.invalidateQueries(['orders', orderId]) + }) + + return () => sub.stop() + }, [ndk, orderId]) + + return query +} +``` + +### Event Parsing Pattern + +```typescript +// Parse event tags into structured data +function parseProductEvent(event: NDKEvent) { + const getTag = (name: string) => + event.tags.find(t => t[0] === name)?.[1] + + const getAllTags = (name: string) => + event.tags.filter(t => t[0] === name).map(t => t[1]) + + return { + id: event.id, + slug: getTag('d'), + title: getTag('title'), + price: parseFloat(getTag('price') || '0'), + currency: event.tags.find(t => t[0] === 'price')?.[2] || 'USD', + images: getAllTags('image'), + shipping: getAllTags('shipping'), + description: event.content, + createdAt: event.created_at, + author: event.pubkey + } +} +``` + +### Relay Pool Pattern + +```typescript +// Separate NDK instances for different purposes +const mainNdk = new NDK({ + explicitRelayUrls: ['wss://relay.damus.io', 'wss://nos.lol'] +}) + +const zapNdk = new NDK({ + explicitRelayUrls: ['wss://relay.damus.io'] // Zap-optimized relays +}) + +const blossomNdk = new NDK({ + explicitRelayUrls: ['wss://blossom.server.com'] // Media server +}) + +await Promise.all([ + mainNdk.connect(), + zapNdk.connect(), + blossomNdk.connect() +]) +``` + +## Troubleshooting + +### Problem: Events Not Received + +**Symptoms:** Subscription doesn't receive events, fetchEvents returns empty Set + +**Solutions:** + +1. Check relay connection: +```typescript +const status = ndk.pool?.connectedRelays() +console.log('Connected relays:', status?.length) +if (status?.length === 0) { + await ndk.connect() +} +``` + +2. Verify filter syntax (especially tags): +```typescript +// ❌ Wrong +{ kinds: [16], 'order': [orderId] } + +// ✅ Correct (note the # prefix for tags) +{ kinds: [16], '#order': [orderId] } +``` + +3. Check timestamps: +```typescript +// Events might be too old/new +const now = Math.floor(Date.now() / 1000) +const filter = { + kinds: [1], + since: now - 86400, // Last 24 hours + until: now +} +``` + +4. Ensure closeOnEose is correct: +```typescript +// For real-time updates +ndk.subscribe(filter, { closeOnEose: false }) + +// For one-time historical fetch +ndk.subscribe(filter, { closeOnEose: true }) +``` + +### Problem: "NDK not initialized" + +**Symptoms:** `ndk` is null/undefined + +**Solutions:** + +1. Initialize before use: +```typescript +// In app entry point +const ndk = new NDK({ explicitRelayUrls: relays }) +await ndk.connect() +``` + +2. Add null checks: +```typescript +const ndk = ndkActions.getNDK() +if (!ndk) throw new Error('NDK not initialized') +``` + +3. Use initialization guard: +```typescript +const ensureNDK = () => { + let ndk = ndkActions.getNDK() + if (!ndk) { + ndk = ndkActions.initialize() + } + return ndk +} +``` + +### Problem: "No active signer" / Cannot Sign Events + +**Symptoms:** Event signing fails, publishing throws error + +**Solutions:** + +1. Check signer is set: +```typescript +if (!ndk.signer) { + throw new Error('Please login first') +} +``` + +2. Ensure blockUntilReady called: +```typescript +const signer = new NDKNip07Signer() +await signer.blockUntilReady() // ← Critical! +ndk.signer = signer +``` + +3. Handle NIP-07 unavailable: +```typescript +try { + const signer = new NDKNip07Signer() + await signer.blockUntilReady() + ndk.signer = signer +} catch (error) { + console.error('Browser extension not available') + // Fallback to other auth method +} +``` + +### Problem: Duplicate Events in Subscriptions + +**Symptoms:** Same event received multiple times + +**Solutions:** + +1. Track processed event IDs: +```typescript +const processedIds = new Set() + +sub.on('event', (event) => { + if (processedIds.has(event.id)) return + processedIds.add(event.id) + handleEvent(event) +}) +``` + +2. Use Map for event storage: +```typescript +const [events, setEvents] = useState>(new Map()) + +sub.on('event', (event) => { + setEvents(prev => new Map(prev).set(event.id, event)) +}) +``` + +### Problem: Connection Timeout + +**Symptoms:** connect() hangs, never resolves + +**Solutions:** + +1. Use timeout wrapper: +```typescript +const connectWithTimeout = async (ndk: NDK, ms = 10000) => { + await Promise.race([ + ndk.connect(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), ms) + ) + ]) +} +``` + +2. Try fewer relays: +```typescript +// Start with reliable relays only +const reliableRelays = ['wss://relay.damus.io'] +const ndk = new NDK({ explicitRelayUrls: reliableRelays }) +``` + +3. Add connection retry: +```typescript +const connectWithRetry = async (ndk: NDK, maxRetries = 3) => { + for (let i = 0; i < maxRetries; i++) { + try { + await connectWithTimeout(ndk, 10000) + return + } catch (error) { + console.log(`Retry ${i + 1}/${maxRetries}`) + if (i === maxRetries - 1) throw error + } + } +} +``` + +### Problem: Subscription Memory Leak + +**Symptoms:** App gets slower, memory usage increases + +**Solutions:** + +1. Always stop subscriptions: +```typescript +useEffect(() => { + const sub = ndk.subscribe(filter, { closeOnEose: false }) + + // ← CRITICAL: cleanup + return () => { + sub.stop() + } +}, [dependencies]) +``` + +2. Track active subscriptions: +```typescript +const activeSubscriptions = new Set() + +const createSub = (filter: NDKFilter) => { + const sub = ndk.subscribe(filter, { closeOnEose: false }) + activeSubscriptions.add(sub) + return sub +} + +const stopAllSubs = () => { + activeSubscriptions.forEach(sub => sub.stop()) + activeSubscriptions.clear() +} +``` + +### Problem: Profile Not Found + +**Symptoms:** fetchProfile() returns null/undefined + +**Solutions:** + +1. Check different relays: +```typescript +// Add more relay URLs +const ndk = new NDK({ + explicitRelayUrls: [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://nos.lol' + ] +}) +``` + +2. Verify pubkey format: +```typescript +// Ensure correct format +if (pubkey.startsWith('npub')) { + const user = ndk.getUser({ npub: pubkey }) +} else if (/^[0-9a-f]{64}$/.test(pubkey)) { + const user = ndk.getUser({ hexpubkey: pubkey }) +} +``` + +3. Handle missing profiles gracefully: +```typescript +const profile = await user.fetchProfile() +const displayName = profile?.name || profile?.displayName || 'Anonymous' +const avatar = profile?.picture || '/default-avatar.png' +``` + +### Problem: Events Published But Not Visible + +**Symptoms:** publish() succeeds but event not found in queries + +**Solutions:** + +1. Verify event was signed: +```typescript +await event.sign() +console.log('Event ID:', event.id) // Should be set +console.log('Signature:', event.sig) // Should exist +``` + +2. Check relay acceptance: +```typescript +const relays = await event.publish() +console.log('Published to relays:', relays) +``` + +3. Query immediately after publish: +```typescript +await event.publish() + +// Wait a moment for relay propagation +await new Promise(resolve => setTimeout(resolve, 1000)) + +const found = await ndk.fetchEvents({ ids: [event.id] }) +console.log('Event found:', found.size > 0) +``` + +### Problem: NIP-46 Connection Fails + +**Symptoms:** Remote signer connection times out or fails + +**Solutions:** + +1. Verify bunker URL format: +```typescript +// Correct format: bunker://?relay=wss://... +const isValidBunkerUrl = (url: string) => { + return url.startsWith('bunker://') && url.includes('?relay=') +} +``` + +2. Ensure local signer is ready: +```typescript +const localSigner = new NDKPrivateKeySigner(privateKey) +await localSigner.blockUntilReady() + +const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner) +await remoteSigner.blockUntilReady() +``` + +3. Store credentials for reconnection: +```typescript +// Save for future sessions +localStorage.setItem('local-signer-key', localSigner.privateKey) +localStorage.setItem('bunker-url', bunkerUrl) +``` + +## Performance Tips + +### Optimize Queries + +```typescript +// ❌ Slow: Multiple sequential queries +const products = await ndk.fetchEvents({ kinds: [30402], authors: [pk1] }) +const orders = await ndk.fetchEvents({ kinds: [16], authors: [pk1] }) +const profiles = await ndk.fetchEvents({ kinds: [0], authors: [pk1] }) + +// ✅ Fast: Parallel queries +const [products, orders, profiles] = await Promise.all([ + ndk.fetchEvents({ kinds: [30402], authors: [pk1] }), + ndk.fetchEvents({ kinds: [16], authors: [pk1] }), + ndk.fetchEvents({ kinds: [0], authors: [pk1] }) +]) +``` + +### Cache Profile Lookups + +```typescript +const profileCache = new Map() + +const getCachedProfile = async (ndk: NDK, pubkey: string) => { + if (profileCache.has(pubkey)) { + return profileCache.get(pubkey)! + } + + const user = ndk.getUser({ hexpubkey: pubkey }) + const profile = await user.fetchProfile() + if (profile) { + profileCache.set(pubkey, profile) + } + + return profile +} +``` + +### Limit Result Sets + +```typescript +// Always use limit to prevent over-fetching +const filter: NDKFilter = { + kinds: [1], + authors: [pubkey], + limit: 50 // ← Important! +} +``` + +### Debounce Subscription Updates + +```typescript +import { debounce } from 'lodash' + +const debouncedUpdate = debounce((event: NDKEvent) => { + handleEvent(event) +}, 300) + +sub.on('event', debouncedUpdate) +``` + +## Testing Tips + +### Mock NDK in Tests + +```typescript +const mockNDK = { + fetchEvents: vi.fn().mockResolvedValue(new Set()), + subscribe: vi.fn().mockReturnValue({ + on: vi.fn(), + stop: vi.fn() + }), + signer: { + user: vi.fn().mockResolvedValue({ pubkey: 'test-pubkey' }) + } +} as unknown as NDK +``` + +### Test Event Creation + +```typescript +const createTestEvent = (overrides?: Partial): NDKEvent => { + return { + id: 'test-id', + kind: 1, + content: 'test content', + tags: [], + created_at: Math.floor(Date.now() / 1000), + pubkey: 'test-pubkey', + sig: 'test-sig', + ...overrides + } as NDKEvent +} +``` + +--- + +For more detailed information, see: +- `ndk-skill.md` - Complete reference +- `quick-reference.md` - Quick lookup +- `examples/` - Code examples + diff --git a/.claude/skills/nostr-tools/SKILL.md b/.claude/skills/nostr-tools/SKILL.md new file mode 100644 index 00000000..905d41c1 --- /dev/null +++ b/.claude/skills/nostr-tools/SKILL.md @@ -0,0 +1,767 @@ +--- +name: nostr-tools +description: This skill should be used when working with nostr-tools library for Nostr protocol operations, including event creation, signing, filtering, relay communication, and NIP implementations. Provides comprehensive knowledge of nostr-tools APIs and patterns. +--- + +# nostr-tools Skill + +This skill provides comprehensive knowledge and patterns for working with nostr-tools, the most popular JavaScript/TypeScript library for Nostr protocol development. + +## When to Use This Skill + +Use this skill when: +- Building Nostr clients or applications +- Creating and signing Nostr events +- Connecting to Nostr relays +- Implementing NIP features +- Working with Nostr keys and cryptography +- Filtering and querying events +- Building relay pools or connections +- Implementing NIP-44/NIP-04 encryption + +## Core Concepts + +### nostr-tools Overview + +nostr-tools provides: +- **Event handling** - Create, sign, verify events +- **Key management** - Generate, convert, encode keys +- **Relay communication** - Connect, subscribe, publish +- **NIP implementations** - NIP-04, NIP-05, NIP-19, NIP-44, etc. +- **Cryptographic operations** - Schnorr signatures, encryption +- **Filter building** - Query events by various criteria + +### Installation + +```bash +npm install nostr-tools +``` + +### Basic Imports + +```javascript +// Core functionality +import { + SimplePool, + generateSecretKey, + getPublicKey, + finalizeEvent, + verifyEvent +} from 'nostr-tools'; + +// NIP-specific imports +import { nip04, nip05, nip19, nip44 } from 'nostr-tools'; + +// Relay operations +import { Relay } from 'nostr-tools/relay'; +``` + +## Key Management + +### Generating Keys + +```javascript +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; + +// Generate new secret key (Uint8Array) +const secretKey = generateSecretKey(); + +// Derive public key +const publicKey = getPublicKey(secretKey); + +console.log('Secret key:', bytesToHex(secretKey)); +console.log('Public key:', publicKey); // hex string +``` + +### Key Encoding (NIP-19) + +```javascript +import { nip19 } from 'nostr-tools'; + +// Encode to bech32 +const nsec = nip19.nsecEncode(secretKey); +const npub = nip19.npubEncode(publicKey); +const note = nip19.noteEncode(eventId); + +console.log(nsec); // nsec1... +console.log(npub); // npub1... +console.log(note); // note1... + +// Decode from bech32 +const { type, data } = nip19.decode(npub); +// type: 'npub', data: publicKey (hex) + +// Encode profile reference (nprofile) +const nprofile = nip19.nprofileEncode({ + pubkey: publicKey, + relays: ['wss://relay.example.com'] +}); + +// Encode event reference (nevent) +const nevent = nip19.neventEncode({ + id: eventId, + relays: ['wss://relay.example.com'], + author: publicKey, + kind: 1 +}); + +// Encode address (naddr) for replaceable events +const naddr = nip19.naddrEncode({ + identifier: 'my-article', + pubkey: publicKey, + kind: 30023, + relays: ['wss://relay.example.com'] +}); +``` + +## Event Operations + +### Event Structure + +```javascript +// Unsigned event template +const eventTemplate = { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'Hello Nostr!' +}; + +// Signed event (after finalizeEvent) +const signedEvent = { + id: '...', // 32-byte sha256 hash as hex + pubkey: '...', // 32-byte public key as hex + created_at: 1234567890, + kind: 1, + tags: [], + content: 'Hello Nostr!', + sig: '...' // 64-byte Schnorr signature as hex +}; +``` + +### Creating and Signing Events + +```javascript +import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'; + +// Create event template +const eventTemplate = { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', publicKey], // Mention + ['e', eventId, '', 'reply'], // Reply + ['t', 'nostr'] // Hashtag + ], + content: 'Hello Nostr!' +}; + +// Sign event +const signedEvent = finalizeEvent(eventTemplate, secretKey); + +// Verify event +const isValid = verifyEvent(signedEvent); +console.log('Event valid:', isValid); +``` + +### Event Kinds + +```javascript +// Common event kinds +const KINDS = { + Metadata: 0, // Profile metadata (NIP-01) + Text: 1, // Short text note (NIP-01) + RecommendRelay: 2, // Relay recommendation + Contacts: 3, // Contact list (NIP-02) + EncryptedDM: 4, // Encrypted DM (NIP-04) + EventDeletion: 5, // Delete events (NIP-09) + Repost: 6, // Repost (NIP-18) + Reaction: 7, // Reaction (NIP-25) + ChannelCreation: 40, // Channel (NIP-28) + ChannelMessage: 42, // Channel message + Zap: 9735, // Zap receipt (NIP-57) + Report: 1984, // Report (NIP-56) + RelayList: 10002, // Relay list (NIP-65) + Article: 30023, // Long-form content (NIP-23) +}; +``` + +### Creating Specific Events + +```javascript +// Profile metadata (kind 0) +const profileEvent = finalizeEvent({ + kind: 0, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: JSON.stringify({ + name: 'Alice', + about: 'Nostr enthusiast', + picture: 'https://example.com/avatar.jpg', + nip05: 'alice@example.com', + lud16: 'alice@getalby.com' + }) +}, secretKey); + +// Contact list (kind 3) +const contactsEvent = finalizeEvent({ + kind: 3, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', pubkey1, 'wss://relay1.com', 'alice'], + ['p', pubkey2, 'wss://relay2.com', 'bob'], + ['p', pubkey3, '', 'carol'] + ], + content: '' // Or JSON relay preferences +}, secretKey); + +// Reply to an event +const replyEvent = finalizeEvent({ + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', rootEventId, '', 'root'], + ['e', parentEventId, '', 'reply'], + ['p', parentEventPubkey] + ], + content: 'This is a reply' +}, secretKey); + +// Reaction (kind 7) +const reactionEvent = finalizeEvent({ + kind: 7, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', eventId], + ['p', eventPubkey] + ], + content: '+' // or '-' or emoji +}, secretKey); + +// Delete event (kind 5) +const deleteEvent = finalizeEvent({ + kind: 5, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', eventIdToDelete], + ['e', anotherEventIdToDelete] + ], + content: 'Deletion reason' +}, secretKey); +``` + +## Relay Communication + +### Using SimplePool + +SimplePool is the recommended way to interact with multiple relays: + +```javascript +import { SimplePool } from 'nostr-tools/pool'; + +const pool = new SimplePool(); +const relays = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.nostr.band' +]; + +// Subscribe to events +const subscription = pool.subscribeMany( + relays, + [ + { + kinds: [1], + authors: [publicKey], + limit: 10 + } + ], + { + onevent(event) { + console.log('Received event:', event); + }, + oneose() { + console.log('End of stored events'); + } + } +); + +// Close subscription when done +subscription.close(); + +// Publish event to all relays +const results = await Promise.allSettled( + pool.publish(relays, signedEvent) +); + +// Query events (returns Promise) +const events = await pool.querySync(relays, { + kinds: [0], + authors: [publicKey] +}); + +// Get single event +const event = await pool.get(relays, { + ids: [eventId] +}); + +// Close pool when done +pool.close(relays); +``` + +### Direct Relay Connection + +```javascript +import { Relay } from 'nostr-tools/relay'; + +const relay = await Relay.connect('wss://relay.damus.io'); + +console.log(`Connected to ${relay.url}`); + +// Subscribe +const sub = relay.subscribe([ + { + kinds: [1], + limit: 100 + } +], { + onevent(event) { + console.log('Event:', event); + }, + oneose() { + console.log('EOSE'); + sub.close(); + } +}); + +// Publish +await relay.publish(signedEvent); + +// Close +relay.close(); +``` + +### Handling Connection States + +```javascript +import { Relay } from 'nostr-tools/relay'; + +const relay = await Relay.connect('wss://relay.example.com'); + +// Listen for disconnect +relay.onclose = () => { + console.log('Relay disconnected'); +}; + +// Check connection status +console.log('Connected:', relay.connected); +``` + +## Filters + +### Filter Structure + +```javascript +const filter = { + // Event IDs + ids: ['abc123...'], + + // Authors (pubkeys) + authors: ['pubkey1', 'pubkey2'], + + // Event kinds + kinds: [1, 6, 7], + + // Tags (single-letter keys) + '#e': ['eventId1', 'eventId2'], + '#p': ['pubkey1'], + '#t': ['nostr', 'bitcoin'], + '#d': ['article-identifier'], + + // Time range + since: 1704067200, // Unix timestamp + until: 1704153600, + + // Limit results + limit: 100, + + // Search (NIP-50, if relay supports) + search: 'nostr protocol' +}; +``` + +### Common Filter Patterns + +```javascript +// User's recent posts +const userPosts = { + kinds: [1], + authors: [userPubkey], + limit: 50 +}; + +// User's profile +const userProfile = { + kinds: [0], + authors: [userPubkey] +}; + +// User's contacts +const userContacts = { + kinds: [3], + authors: [userPubkey] +}; + +// Replies to an event +const replies = { + kinds: [1], + '#e': [eventId] +}; + +// Reactions to an event +const reactions = { + kinds: [7], + '#e': [eventId] +}; + +// Feed from followed users +const feed = { + kinds: [1, 6], + authors: followedPubkeys, + limit: 100 +}; + +// Events mentioning user +const mentions = { + kinds: [1], + '#p': [userPubkey], + limit: 50 +}; + +// Hashtag search +const hashtagEvents = { + kinds: [1], + '#t': ['bitcoin'], + limit: 100 +}; + +// Replaceable event by d-tag +const replaceableEvent = { + kinds: [30023], + authors: [authorPubkey], + '#d': ['article-slug'] +}; +``` + +### Multiple Filters + +```javascript +// Subscribe with multiple filters (OR logic) +const filters = [ + { kinds: [1], authors: [userPubkey], limit: 20 }, + { kinds: [1], '#p': [userPubkey], limit: 20 } +]; + +pool.subscribeMany(relays, filters, { + onevent(event) { + // Receives events matching ANY filter + } +}); +``` + +## Encryption + +### NIP-04 (Legacy DMs) + +```javascript +import { nip04 } from 'nostr-tools'; + +// Encrypt message +const ciphertext = await nip04.encrypt( + secretKey, + recipientPubkey, + 'Hello, this is secret!' +); + +// Create encrypted DM event +const dmEvent = finalizeEvent({ + kind: 4, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', recipientPubkey]], + content: ciphertext +}, secretKey); + +// Decrypt message +const plaintext = await nip04.decrypt( + secretKey, + senderPubkey, + ciphertext +); +``` + +### NIP-44 (Modern Encryption) + +```javascript +import { nip44 } from 'nostr-tools'; + +// Get conversation key (cache this for multiple messages) +const conversationKey = nip44.getConversationKey( + secretKey, + recipientPubkey +); + +// Encrypt +const ciphertext = nip44.encrypt( + 'Hello with NIP-44!', + conversationKey +); + +// Decrypt +const plaintext = nip44.decrypt( + ciphertext, + conversationKey +); +``` + +## NIP Implementations + +### NIP-05 (DNS Identifier) + +```javascript +import { nip05 } from 'nostr-tools'; + +// Query NIP-05 identifier +const profile = await nip05.queryProfile('alice@example.com'); + +if (profile) { + console.log('Pubkey:', profile.pubkey); + console.log('Relays:', profile.relays); +} + +// Verify NIP-05 for a pubkey +const isValid = await nip05.queryProfile('alice@example.com') + .then(p => p?.pubkey === expectedPubkey); +``` + +### NIP-10 (Reply Threading) + +```javascript +import { nip10 } from 'nostr-tools'; + +// Parse reply tags +const parsed = nip10.parse(event); + +console.log('Root:', parsed.root); // Original event +console.log('Reply:', parsed.reply); // Direct parent +console.log('Mentions:', parsed.mentions); // Other mentions +console.log('Profiles:', parsed.profiles); // Mentioned pubkeys +``` + +### NIP-21 (nostr: URIs) + +```javascript +// Parse nostr: URIs +const uri = 'nostr:npub1...'; +const { type, data } = nip19.decode(uri.replace('nostr:', '')); +``` + +### NIP-27 (Content References) + +```javascript +// Parse nostr:npub and nostr:note references in content +const content = 'Check out nostr:npub1abc... and nostr:note1xyz...'; + +const references = content.match(/nostr:(n[a-z]+1[a-z0-9]+)/g); +references?.forEach(ref => { + const decoded = nip19.decode(ref.replace('nostr:', '')); + console.log(decoded.type, decoded.data); +}); +``` + +### NIP-57 (Zaps) + +```javascript +import { nip57 } from 'nostr-tools'; + +// Validate zap receipt +const zapReceipt = await pool.get(relays, { + kinds: [9735], + '#e': [eventId] +}); + +const validatedZap = await nip57.validateZapRequest(zapReceipt); +``` + +## Utilities + +### Hex and Bytes Conversion + +```javascript +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; + +// Convert secret key to hex +const secretKeyHex = bytesToHex(secretKey); + +// Convert hex back to bytes +const secretKeyBytes = hexToBytes(secretKeyHex); +``` + +### Event ID Calculation + +```javascript +import { getEventHash } from 'nostr-tools/pure'; + +// Calculate event ID without signing +const eventId = getEventHash(unsignedEvent); +``` + +### Signature Operations + +```javascript +import { + getSignature, + verifyEvent +} from 'nostr-tools/pure'; + +// Sign event data +const signature = getSignature(unsignedEvent, secretKey); + +// Verify complete event +const isValid = verifyEvent(signedEvent); +``` + +## Best Practices + +### Connection Management + +1. **Use SimplePool** - Manages connections efficiently +2. **Limit concurrent connections** - Don't connect to too many relays +3. **Handle disconnections** - Implement reconnection logic +4. **Close subscriptions** - Always close when done + +### Event Handling + +1. **Verify events** - Always verify signatures +2. **Deduplicate** - Events may come from multiple relays +3. **Handle replaceable events** - Latest by created_at wins +4. **Validate content** - Don't trust event content blindly + +### Key Security + +1. **Never expose secret keys** - Keep in secure storage +2. **Use NIP-07 in browsers** - Let extensions handle signing +3. **Validate input** - Check key formats before use + +### Performance + +1. **Cache events** - Avoid re-fetching +2. **Use filters wisely** - Be specific, use limits +3. **Batch operations** - Combine related queries +4. **Close idle connections** - Free up resources + +## Common Patterns + +### Building a Feed + +```javascript +const pool = new SimplePool(); +const relays = ['wss://relay.damus.io', 'wss://nos.lol']; + +async function loadFeed(followedPubkeys) { + const events = await pool.querySync(relays, { + kinds: [1, 6], + authors: followedPubkeys, + limit: 100 + }); + + // Sort by timestamp + return events.sort((a, b) => b.created_at - a.created_at); +} +``` + +### Real-time Updates + +```javascript +function subscribeToFeed(followedPubkeys, onEvent) { + return pool.subscribeMany( + relays, + [{ kinds: [1, 6], authors: followedPubkeys }], + { + onevent: onEvent, + oneose() { + console.log('Caught up with stored events'); + } + } + ); +} +``` + +### Profile Loading + +```javascript +async function loadProfile(pubkey) { + const [metadata] = await pool.querySync(relays, { + kinds: [0], + authors: [pubkey], + limit: 1 + }); + + if (metadata) { + return JSON.parse(metadata.content); + } + return null; +} +``` + +### Event Deduplication + +```javascript +const seenEvents = new Set(); + +function handleEvent(event) { + if (seenEvents.has(event.id)) { + return; // Skip duplicate + } + seenEvents.add(event.id); + + // Process event... +} +``` + +## Troubleshooting + +### Common Issues + +**Events not publishing:** +- Check relay is writable +- Verify event is properly signed +- Check relay's accepted kinds + +**Subscription not receiving events:** +- Verify filter syntax +- Check relay has matching events +- Ensure subscription isn't closed + +**Signature verification fails:** +- Check event structure is correct +- Verify keys are in correct format +- Ensure event hasn't been modified + +**NIP-05 lookup fails:** +- Check CORS headers on server +- Verify .well-known path is correct +- Handle network timeouts + +## References + +- **nostr-tools GitHub**: https://github.com/nbd-wtf/nostr-tools +- **Nostr Protocol**: https://github.com/nostr-protocol/nostr +- **NIPs Repository**: https://github.com/nostr-protocol/nips +- **NIP-01 (Basic Protocol)**: https://github.com/nostr-protocol/nips/blob/master/01.md + +## Related Skills + +- **nostr** - Nostr protocol fundamentals +- **svelte** - Building Nostr UIs with Svelte +- **applesauce-core** - Higher-level Nostr client utilities +- **applesauce-signers** - Nostr signing abstractions diff --git a/.claude/skills/nostr-websocket/SKILL.md b/.claude/skills/nostr-websocket/SKILL.md new file mode 100644 index 00000000..8d58f057 --- /dev/null +++ b/.claude/skills/nostr-websocket/SKILL.md @@ -0,0 +1,978 @@ +--- +name: nostr-websocket +description: This skill should be used when implementing, debugging, or discussing WebSocket connections for Nostr relays. Provides comprehensive knowledge of RFC 6455 WebSocket protocol, production-ready implementation patterns in Go (khatru), C++ (strfry), and Rust (nostr-rs-relay), including connection lifecycle, message framing, subscription management, and performance optimization techniques specific to Nostr relay operations. +--- + +# Nostr WebSocket Programming + +## Overview + +Implement robust, high-performance WebSocket connections for Nostr relays following RFC 6455 specifications and battle-tested production patterns. This skill provides comprehensive guidance on WebSocket protocol fundamentals, connection management, message handling, and language-specific implementation strategies using proven codebases. + +## Core WebSocket Protocol (RFC 6455) + +### Connection Upgrade Handshake + +The WebSocket connection begins with an HTTP upgrade request: + +**Client Request Headers:** +- `Upgrade: websocket` - Required +- `Connection: Upgrade` - Required +- `Sec-WebSocket-Key` - 16-byte random value, base64-encoded +- `Sec-WebSocket-Version: 13` - Required +- `Origin` - Required for browser clients (security) + +**Server Response (HTTP 101):** +- `HTTP/1.1 101 Switching Protocols` +- `Upgrade: websocket` +- `Connection: Upgrade` +- `Sec-WebSocket-Accept` - SHA-1(client_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"), base64-encoded + +**Security validation:** Always verify the `Sec-WebSocket-Accept` value matches expected computation. Reject connections with missing or incorrect values. + +### Frame Structure + +WebSocket frames use binary encoding with variable-length fields: + +**Header (minimum 2 bytes):** +- **FIN bit** (1 bit) - Final fragment indicator +- **RSV1-3** (3 bits) - Reserved for extensions (must be 0) +- **Opcode** (4 bits) - Frame type identifier +- **MASK bit** (1 bit) - Payload masking indicator +- **Payload length** (7, 7+16, or 7+64 bits) - Variable encoding + +**Payload length encoding:** +- 0-125: Direct 7-bit value +- 126: Next 16 bits contain length +- 127: Next 64 bits contain length + +### Frame Opcodes + +**Data Frames:** +- `0x0` - Continuation frame +- `0x1` - Text frame (UTF-8) +- `0x2` - Binary frame + +**Control Frames:** +- `0x8` - Connection close +- `0x9` - Ping +- `0xA` - Pong + +**Control frame constraints:** +- Maximum 125-byte payload +- Cannot be fragmented +- Must be processed immediately + +### Masking Requirements + +**Critical security requirement:** +- Client-to-server frames MUST be masked +- Server-to-client frames MUST NOT be masked +- Masking uses XOR with 4-byte random key +- Prevents cache poisoning and intermediary attacks + +**Masking algorithm:** +``` +transformed[i] = original[i] XOR masking_key[i MOD 4] +``` + +### Ping/Pong Keep-Alive + +**Purpose:** Detect broken connections and maintain NAT traversal + +**Pattern:** +1. Either endpoint sends Ping (0x9) with optional payload +2. Recipient responds with Pong (0xA) containing identical payload +3. Implement timeouts to detect unresponsive connections + +**Nostr relay recommendations:** +- Send pings every 30-60 seconds +- Timeout after 60-120 seconds without pong response +- Close connections exceeding timeout threshold + +### Close Handshake + +**Initiation:** Either peer sends Close frame (0x8) + +**Close frame structure:** +- Optional 2-byte status code +- Optional UTF-8 reason string + +**Common status codes:** +- `1000` - Normal closure +- `1001` - Going away (server shutdown/navigation) +- `1002` - Protocol error +- `1003` - Unsupported data type +- `1006` - Abnormal closure (no close frame) +- `1011` - Server error + +**Proper shutdown sequence:** +1. Initiator sends Close frame +2. Recipient responds with Close frame +3. Both close TCP connection + +## Nostr Relay WebSocket Architecture + +### Message Flow Overview + +``` +Client Relay + | | + |--- HTTP Upgrade ------->| + |<-- 101 Switching -------| + | | + |--- ["EVENT", {...}] --->| (Validate, store, broadcast) + |<-- ["OK", id, ...] -----| + | | + |--- ["REQ", id, {...}]-->| (Query + subscribe) + |<-- ["EVENT", id, {...}]-| (Stored events) + |<-- ["EOSE", id] --------| (End of stored) + |<-- ["EVENT", id, {...}]-| (Real-time events) + | | + |--- ["CLOSE", id] ------>| (Unsubscribe) + | | + |--- Close Frame -------->| + |<-- Close Frame ---------| +``` + +### Critical Concurrency Considerations + +**Write concurrency:** WebSocket libraries panic/error on concurrent writes. Always protect writes with: +- Mutex locks (Go, C++) +- Single-writer goroutine/thread pattern +- Message queue with dedicated sender + +**Read concurrency:** Concurrent reads generally allowed but not useful - implement single reader loop per connection. + +**Subscription management:** Concurrent access to subscription maps requires synchronization or lock-free data structures. + +## Language-Specific Implementation Patterns + +### Go Implementation (khatru-style) + +**Recommended library:** `github.com/fasthttp/websocket` + +**Connection structure:** +```go +type WebSocket struct { + conn *websocket.Conn + mutex sync.Mutex // Protects writes + + Request *http.Request // Original HTTP request + Context context.Context // Cancellation context + cancel context.CancelFunc + + // NIP-42 authentication + Challenge string + AuthedPublicKey string + + // Concurrent session management + negentropySessions *xsync.MapOf[string, *NegentropySession] +} + +// Thread-safe write +func (ws *WebSocket) WriteJSON(v any) error { + ws.mutex.Lock() + defer ws.mutex.Unlock() + return ws.conn.WriteJSON(v) +} +``` + +**Lifecycle pattern (dual goroutines):** +```go +// Read goroutine +go func() { + defer cleanup() + + ws.conn.SetReadLimit(maxMessageSize) + ws.conn.SetReadDeadline(time.Now().Add(pongWait)) + ws.conn.SetPongHandler(func(string) error { + ws.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + typ, msg, err := ws.conn.ReadMessage() + if err != nil { + return // Connection closed + } + + if typ == websocket.PingMessage { + ws.WriteMessage(websocket.PongMessage, nil) + continue + } + + // Parse and handle message in separate goroutine + go handleMessage(msg) + } +}() + +// Write/ping goroutine +go func() { + defer cleanup() + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := ws.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +}() +``` + +**Key patterns:** +- **Mutex-protected writes** - Prevent concurrent write panics +- **Context-based lifecycle** - Clean cancellation hierarchy +- **Swap-delete for subscriptions** - O(1) removal from listener arrays +- **Zero-copy string conversion** - `unsafe.String()` for message parsing +- **Goroutine-per-message** - Sequential parsing, concurrent handling +- **Hook-based extensibility** - Plugin architecture without core modifications + +**Configuration constants:** +```go +WriteWait: 10 * time.Second // Write timeout +PongWait: 60 * time.Second // Pong timeout +PingPeriod: 30 * time.Second // Ping interval (< PongWait) +MaxMessageSize: 512000 // 512 KB limit +``` + +**Subscription management:** +```go +type listenerSpec struct { + id string + cancel context.CancelCauseFunc + index int + subrelay *Relay +} + +// Efficient removal with swap-delete +func (rl *Relay) removeListenerId(ws *WebSocket, id string) { + rl.clientsMutex.Lock() + defer rl.clientsMutex.Unlock() + + if specs, ok := rl.clients[ws]; ok { + for i := len(specs) - 1; i >= 0; i-- { + if specs[i].id == id { + specs[i].cancel(ErrSubscriptionClosedByClient) + specs[i] = specs[len(specs)-1] + specs = specs[:len(specs)-1] + rl.clients[ws] = specs + break + } + } + } +} +``` + +For detailed khatru implementation examples, see [references/khatru_implementation.md](references/khatru_implementation.md). + +### C++ Implementation (strfry-style) + +**Recommended library:** Custom fork of `uWebSockets` with epoll + +**Architecture highlights:** +- Single-threaded I/O using epoll for connection multiplexing +- Thread pool architecture: 6 specialized pools (WebSocket, Ingester, Writer, ReqWorker, ReqMonitor, Negentropy) +- "Shared nothing" message-passing design eliminates lock contention +- Deterministic thread assignment: `connId % numThreads` + +**Connection structure:** +```cpp +struct ConnectionState { + uint64_t connId; + std::string remoteAddr; + flat_str subId; // Subscription ID + std::shared_ptr sub; + PerMessageDeflate pmd; // Compression state + uint64_t latestEventSent = 0; + + // Message parsing state + secp256k1_context *secpCtx; + std::string parseBuffer; +}; +``` + +**Message handling pattern:** +```cpp +// WebSocket message callback +ws->onMessage([=](std::string_view msg, uWS::OpCode opCode) { + // Reuse buffer to avoid allocations + state->parseBuffer.assign(msg.data(), msg.size()); + + try { + auto json = nlohmann::json::parse(state->parseBuffer); + auto cmdStr = json[0].get(); + + if (cmdStr == "EVENT") { + // Send to Ingester thread pool + auto packed = MsgIngester::Message(connId, std::move(json)); + tpIngester->dispatchToThread(connId, std::move(packed)); + } + else if (cmdStr == "REQ") { + // Send to ReqWorker thread pool + auto packed = MsgReq::Message(connId, std::move(json)); + tpReqWorker->dispatchToThread(connId, std::move(packed)); + } + } catch (std::exception &e) { + sendNotice("Error: " + std::string(e.what())); + } +}); +``` + +**Critical performance optimizations:** + +1. **Event batching** - Serialize event JSON once, reuse for thousands of subscribers: +```cpp +// Single serialization +std::string eventJson = event.toJson(); + +// Broadcast to all matching subscriptions +for (auto &[connId, sub] : activeSubscriptions) { + if (sub->matches(event)) { + sendToConnection(connId, eventJson); // Reuse serialized JSON + } +} +``` + +2. **Move semantics** - Zero-copy message passing: +```cpp +tpIngester->dispatchToThread(connId, std::move(message)); +``` + +3. **Pre-allocated buffers** - Single reusable buffer per connection: +```cpp +state->parseBuffer.assign(msg.data(), msg.size()); +``` + +4. **std::variant dispatch** - Type-safe without virtual function overhead: +```cpp +std::variant message; +std::visit([](auto&& msg) { msg.handle(); }, message); +``` + +For detailed strfry implementation examples, see [references/strfry_implementation.md](references/strfry_implementation.md). + +### Rust Implementation (nostr-rs-relay-style) + +**Recommended libraries:** +- `tokio-tungstenite 0.17` - Async WebSocket support +- `tokio 1.x` - Async runtime +- `serde_json` - Message parsing + +**WebSocket configuration:** +```rust +let config = WebSocketConfig { + max_send_queue: Some(1024), + max_message_size: settings.limits.max_ws_message_bytes, + max_frame_size: settings.limits.max_ws_frame_bytes, + ..Default::default() +}; + +let ws_stream = WebSocketStream::from_raw_socket( + upgraded, + Role::Server, + Some(config), +).await; +``` + +**Connection state:** +```rust +pub struct ClientConn { + client_ip_addr: String, + client_id: Uuid, + subscriptions: HashMap, + max_subs: usize, + auth: Nip42AuthState, +} + +pub enum Nip42AuthState { + NoAuth, + Challenge(String), + AuthPubkey(String), +} +``` + +**Async message loop with tokio::select!:** +```rust +async fn nostr_server( + repo: Arc, + mut ws_stream: WebSocketStream, + broadcast: Sender, + mut shutdown: Receiver<()>, +) { + let mut conn = ClientConn::new(client_ip); + let mut bcast_rx = broadcast.subscribe(); + let mut ping_interval = tokio::time::interval(Duration::from_secs(300)); + + loop { + tokio::select! { + // Handle shutdown + _ = shutdown.recv() => { break; } + + // Send periodic pings + _ = ping_interval.tick() => { + ws_stream.send(Message::Ping(Vec::new())).await.ok(); + } + + // Handle broadcast events (real-time) + Ok(event) = bcast_rx.recv() => { + for (id, sub) in conn.subscriptions() { + if sub.interested_in_event(&event) { + let msg = format!("[\"EVENT\",\"{}\",{}]", id, + serde_json::to_string(&event)?); + ws_stream.send(Message::Text(msg)).await.ok(); + } + } + } + + // Handle incoming client messages + Some(result) = ws_stream.next() => { + match result { + Ok(Message::Text(msg)) => { + handle_nostr_message(&msg, &mut conn).await; + } + Ok(Message::Binary(_)) => { + send_notice("binary messages not accepted").await; + } + Ok(Message::Ping(_) | Message::Pong(_)) => { + continue; // Auto-handled by tungstenite + } + Ok(Message::Close(_)) | Err(_) => { + break; + } + _ => {} + } + } + } + } +} +``` + +**Subscription filtering:** +```rust +pub struct ReqFilter { + pub ids: Option>, + pub kinds: Option>, + pub since: Option, + pub until: Option, + pub authors: Option>, + pub limit: Option, + pub tags: Option>>, +} + +impl ReqFilter { + pub fn interested_in_event(&self, event: &Event) -> bool { + self.ids_match(event) + && self.since.map_or(true, |t| event.created_at >= t) + && self.until.map_or(true, |t| event.created_at <= t) + && self.kind_match(event.kind) + && self.authors_match(event) + && self.tag_match(event) + } + + fn ids_match(&self, event: &Event) -> bool { + self.ids.as_ref() + .map_or(true, |ids| ids.iter().any(|id| event.id.starts_with(id))) + } +} +``` + +**Error handling:** +```rust +match ws_stream.next().await { + Some(Ok(Message::Text(msg))) => { /* handle */ } + + Some(Err(WsError::Capacity(MessageTooLong{size, max_size}))) => { + send_notice(&format!("message too large ({} > {})", size, max_size)).await; + continue; + } + + None | Some(Ok(Message::Close(_))) => { + info!("client closed connection"); + break; + } + + Some(Err(WsError::Io(e))) => { + warn!("IO error: {:?}", e); + break; + } + + _ => { break; } +} +``` + +For detailed Rust implementation examples, see [references/rust_implementation.md](references/rust_implementation.md). + +## Common Implementation Patterns + +### Pattern 1: Dual Goroutine/Task Architecture + +**Purpose:** Separate read and write concerns, enable ping/pong management + +**Structure:** +- **Reader goroutine/task:** Blocks on `ReadMessage()`, handles incoming frames +- **Writer goroutine/task:** Sends periodic pings, processes outgoing message queue + +**Benefits:** +- Natural separation of concerns +- Ping timer doesn't block message processing +- Clean shutdown coordination via context/channels + +### Pattern 2: Subscription Lifecycle + +**Create subscription (REQ):** +1. Parse filter from client message +2. Query database for matching stored events +3. Send stored events to client +4. Send EOSE (End of Stored Events) +5. Add subscription to active listeners for real-time events + +**Handle real-time event:** +1. Check all active subscriptions +2. For each matching subscription: + - Apply filter matching logic + - Send EVENT message to client +3. Track broadcast count for monitoring + +**Close subscription (CLOSE):** +1. Find subscription by ID +2. Cancel subscription context +3. Remove from active listeners +4. Clean up resources + +### Pattern 3: Write Serialization + +**Problem:** Concurrent writes cause panics/errors in WebSocket libraries + +**Solutions:** + +**Mutex approach (Go, C++):** +```go +func (ws *WebSocket) WriteJSON(v any) error { + ws.mutex.Lock() + defer ws.mutex.Unlock() + return ws.conn.WriteJSON(v) +} +``` + +**Single-writer goroutine (Alternative):** +```go +type writeMsg struct { + data []byte + done chan error +} + +go func() { + for msg := range writeChan { + msg.done <- ws.conn.WriteMessage(websocket.TextMessage, msg.data) + } +}() +``` + +### Pattern 4: Connection Cleanup + +**Essential cleanup steps:** +1. Cancel all subscription contexts +2. Stop ping ticker/interval +3. Remove connection from active clients map +4. Close WebSocket connection +5. Close TCP connection +6. Log connection statistics + +**Go cleanup function:** +```go +kill := func() { + // Cancel contexts + cancel() + ws.cancel() + + // Stop timers + ticker.Stop() + + // Remove from tracking + rl.removeClientAndListeners(ws) + + // Close connection + ws.conn.Close() + + // Trigger hooks + for _, ondisconnect := range rl.OnDisconnect { + ondisconnect(ctx) + } +} +defer kill() +``` + +### Pattern 5: Event Broadcasting Optimization + +**Naive approach (inefficient):** +```go +// DON'T: Serialize for each subscriber +for _, listener := range listeners { + if listener.filter.Matches(event) { + json := serializeEvent(event) // Repeated work! + listener.ws.WriteJSON(json) + } +} +``` + +**Optimized approach:** +```go +// DO: Serialize once, reuse for all subscribers +eventJSON, err := json.Marshal(event) +if err != nil { + return +} + +for _, listener := range listeners { + if listener.filter.Matches(event) { + listener.ws.WriteMessage(websocket.TextMessage, eventJSON) + } +} +``` + +**Savings:** For 1000 subscribers, reduces 1000 JSON serializations to 1. + +## Security Considerations + +### Origin Validation + +Always validate the `Origin` header for browser-based clients: + +```go +upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + return isAllowedOrigin(origin) // Implement allowlist + }, +} +``` + +**Default behavior:** Most libraries reject all cross-origin connections. Override with caution. + +### Rate Limiting + +Implement rate limits for: +- Connection establishment (per IP) +- Message throughput (per connection) +- Subscription creation (per connection) +- Event publication (per connection, per pubkey) + +```go +// Example: Connection rate limiting +type rateLimiter struct { + connections map[string]*rate.Limiter + mu sync.Mutex +} + +func (rl *Relay) checkRateLimit(ip string) bool { + limiter := rl.rateLimiter.getLimiter(ip) + return limiter.Allow() +} +``` + +### Message Size Limits + +Configure limits to prevent memory exhaustion: + +```go +ws.conn.SetReadLimit(maxMessageSize) // e.g., 512 KB +``` + +```rust +max_message_size: Some(512_000), +max_frame_size: Some(16_384), +``` + +### Subscription Limits + +Prevent resource exhaustion: +- Max subscriptions per connection (typically 10-20) +- Max subscription ID length (prevent hash collision attacks) +- Require specific filters (prevent full database scans) + +```rust +const MAX_SUBSCRIPTION_ID_LEN: usize = 256; +const MAX_SUBS_PER_CLIENT: usize = 20; + +if subscriptions.len() >= MAX_SUBS_PER_CLIENT { + return Err(Error::SubMaxExceededError); +} +``` + +### Authentication (NIP-42) + +Implement challenge-response authentication: + +1. **Generate challenge on connect:** +```go +challenge := make([]byte, 8) +rand.Read(challenge) +ws.Challenge = hex.EncodeToString(challenge) +``` + +2. **Send AUTH challenge when required:** +```json +["AUTH", ""] +``` + +3. **Validate AUTH event:** +```go +func validateAuthEvent(event *Event, challenge, relayURL string) bool { + // Check kind 22242 + if event.Kind != 22242 { return false } + + // Check challenge in tags + if !hasTag(event, "challenge", challenge) { return false } + + // Check relay URL + if !hasTag(event, "relay", relayURL) { return false } + + // Check timestamp (within 10 minutes) + if abs(time.Now().Unix() - event.CreatedAt) > 600 { return false } + + // Verify signature + return event.CheckSignature() +} +``` + +## Performance Optimization Techniques + +### 1. Connection Pooling + +Reuse connections for database queries: +```go +db, _ := sql.Open("postgres", dsn) +db.SetMaxOpenConns(25) +db.SetMaxIdleConns(5) +db.SetConnMaxLifetime(5 * time.Minute) +``` + +### 2. Event Caching + +Cache frequently accessed events: +```go +type EventCache struct { + cache *lru.Cache + mu sync.RWMutex +} + +func (ec *EventCache) Get(id string) (*Event, bool) { + ec.mu.RLock() + defer ec.mu.RUnlock() + if val, ok := ec.cache.Get(id); ok { + return val.(*Event), true + } + return nil, false +} +``` + +### 3. Batch Database Queries + +Execute queries concurrently for multi-filter subscriptions: +```go +var wg sync.WaitGroup +for _, filter := range filters { + wg.Add(1) + go func(f Filter) { + defer wg.Done() + events := queryDatabase(f) + sendEvents(events) + }(filter) +} +wg.Wait() +sendEOSE() +``` + +### 4. Compression (permessage-deflate) + +Enable WebSocket compression for text frames: +```go +upgrader := websocket.Upgrader{ + EnableCompression: true, +} +``` + +**Typical savings:** 60-80% bandwidth reduction for JSON messages + +**Trade-off:** Increased CPU usage (usually worthwhile) + +### 5. Monitoring and Metrics + +Track key performance indicators: +- Connections (active, total, per IP) +- Messages (received, sent, per type) +- Events (stored, broadcast, per second) +- Subscriptions (active, per connection) +- Query latency (p50, p95, p99) +- Database pool utilization + +```go +// Prometheus-style metrics +type Metrics struct { + Connections prometheus.Gauge + MessagesRecv prometheus.Counter + MessagesSent prometheus.Counter + EventsStored prometheus.Counter + QueryDuration prometheus.Histogram +} +``` + +## Testing WebSocket Implementations + +### Unit Testing + +Test individual components in isolation: + +```go +func TestFilterMatching(t *testing.T) { + filter := Filter{ + Kinds: []int{1, 3}, + Authors: []string{"abc123"}, + } + + event := &Event{ + Kind: 1, + PubKey: "abc123", + } + + if !filter.Matches(event) { + t.Error("Expected filter to match event") + } +} +``` + +### Integration Testing + +Test WebSocket connection handling: + +```go +func TestWebSocketConnection(t *testing.T) { + // Start test server + server := startTestRelay(t) + defer server.Close() + + // Connect client + ws, _, err := websocket.DefaultDialer.Dial(server.URL, nil) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + defer ws.Close() + + // Send REQ + req := `["REQ","test",{"kinds":[1]}]` + if err := ws.WriteMessage(websocket.TextMessage, []byte(req)); err != nil { + t.Fatalf("Failed to send REQ: %v", err) + } + + // Read EOSE + _, msg, err := ws.ReadMessage() + if err != nil { + t.Fatalf("Failed to read message: %v", err) + } + + if !strings.Contains(string(msg), "EOSE") { + t.Errorf("Expected EOSE, got: %s", msg) + } +} +``` + +### Load Testing + +Use tools like `websocat` or custom scripts: + +```bash +# Connect 1000 concurrent clients +for i in {1..1000}; do + (websocat "ws://localhost:8080" <<< '["REQ","test",{"kinds":[1]}]' &) +done +``` + +Monitor server metrics during load testing: +- CPU usage +- Memory consumption +- Connection count +- Message throughput +- Database query rate + +## Debugging and Troubleshooting + +### Common Issues + +**1. Concurrent write panic/error** + +**Symptom:** `concurrent write to websocket connection` error + +**Solution:** Ensure all writes protected by mutex or use single-writer pattern + +**2. Connection timeouts** + +**Symptom:** Connections close after 60 seconds + +**Solution:** Implement ping/pong mechanism properly: +```go +ws.SetPongHandler(func(string) error { + ws.SetReadDeadline(time.Now().Add(pongWait)) + return nil +}) +``` + +**3. Memory leaks** + +**Symptom:** Memory usage grows over time + +**Common causes:** +- Subscriptions not removed on disconnect +- Event channels not closed +- Goroutines not terminated + +**Solution:** Ensure cleanup function called on disconnect + +**4. Slow subscription queries** + +**Symptom:** EOSE delayed by seconds + +**Solution:** +- Add database indexes on filtered columns +- Implement query timeouts +- Consider caching frequently accessed events + +### Logging Best Practices + +Log critical events with context: + +```go +log.Printf( + "connection closed: cid=%s ip=%s duration=%v sent=%d recv=%d", + conn.ID, + conn.IP, + time.Since(conn.ConnectedAt), + conn.EventsSent, + conn.EventsRecv, +) +``` + +Use log levels appropriately: +- **DEBUG:** Message parsing, filter matching +- **INFO:** Connection lifecycle, subscription changes +- **WARN:** Rate limit violations, invalid messages +- **ERROR:** Database errors, unexpected panics + +## Resources + +This skill includes comprehensive reference documentation with production code examples: + +### references/ + +- **websocket_protocol.md** - Complete RFC 6455 specification details including frame structure, opcodes, masking algorithm, and security considerations +- **khatru_implementation.md** - Go WebSocket patterns from khatru including connection lifecycle, subscription management, and performance optimizations (3000+ lines) +- **strfry_implementation.md** - C++ high-performance patterns from strfry including thread pool architecture, message batching, and zero-copy techniques (2000+ lines) +- **rust_implementation.md** - Rust async patterns from nostr-rs-relay including tokio::select! usage, error handling, and subscription filtering (2000+ lines) + +Load these references when implementing specific language solutions or troubleshooting complex WebSocket issues. \ No newline at end of file diff --git a/.claude/skills/nostr-websocket/references/khatru_implementation.md b/.claude/skills/nostr-websocket/references/khatru_implementation.md new file mode 100644 index 00000000..3f4fff23 --- /dev/null +++ b/.claude/skills/nostr-websocket/references/khatru_implementation.md @@ -0,0 +1,1275 @@ +# Go WebSocket Implementation for Nostr Relays (khatru patterns) + +This reference documents production-ready WebSocket patterns from the khatru Nostr relay implementation in Go. + +## Repository Information + +- **Project:** khatru - Nostr relay framework +- **Repository:** https://github.com/fiatjaf/khatru +- **Language:** Go +- **WebSocket Library:** `github.com/fasthttp/websocket` +- **Architecture:** Hook-based plugin system with dual-goroutine per connection + +## Core Architecture + +### Relay Structure + +```go +// relay.go, lines 54-119 +type Relay struct { + // Service configuration + ServiceURL string + upgrader websocket.Upgrader + + // WebSocket lifecycle hooks + RejectConnection []func(r *http.Request) bool + OnConnect []func(ctx context.Context) + OnDisconnect []func(ctx context.Context) + + // Event processing hooks + RejectEvent []func(ctx context.Context, event *nostr.Event) (reject bool, msg string) + OverwriteDeletionOutcome []func(ctx context.Context, target *nostr.Event, deletion *nostr.Event) (acceptDeletion bool, msg string) + StoreEvent []func(ctx context.Context, event *nostr.Event) error + ReplaceEvent []func(ctx context.Context, event *nostr.Event) error + DeleteEvent []func(ctx context.Context, event *nostr.Event) error + OnEventSaved []func(ctx context.Context, event *nostr.Event) + OnEphemeralEvent []func(ctx context.Context, event *nostr.Event) + + // Filter/query hooks + RejectFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) + OverwriteFilter []func(ctx context.Context, filter *nostr.Filter) + QueryEvents []func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) + CountEvents []func(ctx context.Context, filter nostr.Filter) (int64, error) + CountEventsHLL []func(ctx context.Context, filter nostr.Filter, offset int) (int64, *hyperloglog.HyperLogLog, error) + + // Broadcast control + PreventBroadcast []func(ws *WebSocket, event *nostr.Event) bool + OverwriteResponseEvent []func(ctx context.Context, event *nostr.Event) + + // Client tracking + clients map[*WebSocket][]listenerSpec + listeners []listener + clientsMutex sync.Mutex + + // WebSocket parameters + WriteWait time.Duration // Default: 10 seconds + PongWait time.Duration // Default: 60 seconds + PingPeriod time.Duration // Default: 30 seconds + MaxMessageSize int64 // Default: 512000 bytes + + // Router support (for multi-relay setups) + routes []Route + getSubRelayFromEvent func(*nostr.Event) *Relay + getSubRelayFromFilter func(nostr.Filter) *Relay + + // Protocol extensions + Negentropy bool // NIP-77 support +} +``` + +### WebSocket Configuration + +```go +// relay.go, lines 31-35 +upgrader: websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +}, +``` + +**Key configuration choices:** +- **1 KB read/write buffers:** Small buffers for many concurrent connections +- **Allow all origins:** Nostr is designed for public relays; adjust for private relays +- **No compression by default:** Can be enabled with `EnableCompression: true` + +**Recommended production settings:** +```go +upgrader: websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + EnableCompression: true, // 60-80% bandwidth reduction + CheckOrigin: func(r *http.Request) bool { + // For public relays: return true + // For private relays: validate origin + origin := r.Header.Get("Origin") + return isAllowedOrigin(origin) + }, +}, +``` + +## WebSocket Connection Structure + +### Connection Wrapper + +```go +// websocket.go, lines 12-32 +type WebSocket struct { + conn *websocket.Conn + mutex sync.Mutex // Protects all write operations + + // Original HTTP request (for IP, headers, etc.) + Request *http.Request + + // Connection lifecycle context + Context context.Context + cancel context.CancelFunc + + // NIP-42 authentication + Challenge string // Random 8-byte hex string + AuthedPublicKey string // Authenticated pubkey after AUTH + Authed chan struct{} // Closed when authenticated + authLock sync.Mutex + + // NIP-77 negentropy sessions (for efficient set reconciliation) + negentropySessions *xsync.MapOf[string, *NegentropySession] +} +``` + +**Design decisions:** + +1. **Mutex for writes:** WebSocket library panics on concurrent writes; mutex is simplest solution +2. **Context-based lifecycle:** Clean cancellation propagation to all operations +3. **Original request preservation:** Enables IP extraction, header inspection +4. **NIP-42 challenge storage:** No database lookup needed for authentication +5. **Lock-free session map:** `xsync.MapOf` provides concurrent access without locks + +### Thread-Safe Write Operations + +```go +// websocket.go, lines 34-46 +func (ws *WebSocket) WriteJSON(any any) error { + ws.mutex.Lock() + err := ws.conn.WriteJSON(any) + ws.mutex.Unlock() + return err +} + +func (ws *WebSocket) WriteMessage(t int, b []byte) error { + ws.mutex.Lock() + err := ws.conn.WriteMessage(t, b) + ws.mutex.Unlock() + return err +} +``` + +**Critical pattern:** ALL writes to WebSocket MUST be protected by mutex + +**Common mistake:** +```go +// DON'T DO THIS - Race condition! +go func() { + ws.conn.WriteJSON(msg1) // Not protected +}() +go func() { + ws.conn.WriteJSON(msg2) // Not protected +}() +``` + +**Correct approach:** +```go +// DO THIS - Protected writes +go func() { + ws.WriteJSON(msg1) // Uses mutex +}() +go func() { + ws.WriteJSON(msg2) // Uses mutex +}() +``` + +## Connection Lifecycle + +### HTTP to WebSocket Upgrade + +```go +// handlers.go, lines 29-52 +func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // CORS middleware for non-WebSocket requests + corsMiddleware := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{ + http.MethodHead, + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + }, + AllowedHeaders: []string{"Authorization", "*"}, + MaxAge: 86400, + }) + + // Route based on request type + if r.Header.Get("Upgrade") == "websocket" { + rl.HandleWebsocket(w, r) // WebSocket connection + } else if r.Header.Get("Accept") == "application/nostr+json" { + corsMiddleware.Handler(http.HandlerFunc(rl.HandleNIP11)).ServeHTTP(w, r) // NIP-11 metadata + } else if r.Header.Get("Content-Type") == "application/nostr+json+rpc" { + corsMiddleware.Handler(http.HandlerFunc(rl.HandleNIP86)).ServeHTTP(w, r) // NIP-86 management + } else { + corsMiddleware.Handler(rl.serveMux).ServeHTTP(w, r) // Other routes + } +} +``` + +**Pattern:** Single HTTP handler multiplexes all request types by headers + +### WebSocket Upgrade Process + +```go +// handlers.go, lines 55-105 +func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) { + // Pre-upgrade rejection hooks + for _, reject := range rl.RejectConnection { + if reject(r) { + w.WriteHeader(429) // Too Many Requests + return + } + } + + // Perform WebSocket upgrade + conn, err := rl.upgrader.Upgrade(w, r, nil) + if err != nil { + rl.Log.Printf("failed to upgrade websocket: %v\n", err) + return + } + + // Create ping ticker for keep-alive + ticker := time.NewTicker(rl.PingPeriod) + + // Generate NIP-42 authentication challenge + challenge := make([]byte, 8) + rand.Read(challenge) + + // Initialize WebSocket wrapper + ws := &WebSocket{ + conn: conn, + Request: r, + Challenge: hex.EncodeToString(challenge), + negentropySessions: xsync.NewMapOf[string, *NegentropySession](), + } + ws.Context, ws.cancel = context.WithCancel(context.Background()) + + // Register client + rl.clientsMutex.Lock() + rl.clients[ws] = make([]listenerSpec, 0, 2) + rl.clientsMutex.Unlock() + + // Create connection context with WebSocket reference + ctx, cancel := context.WithCancel( + context.WithValue(context.Background(), wsKey, ws), + ) + + // Cleanup function for both goroutines + kill := func() { + // Trigger disconnect hooks + for _, ondisconnect := range rl.OnDisconnect { + ondisconnect(ctx) + } + + // Stop timers and cancel contexts + ticker.Stop() + cancel() + ws.cancel() + + // Close connection + ws.conn.Close() + + // Remove from tracking + rl.removeClientAndListeners(ws) + } + + // Launch read and write goroutines + go readLoop(ws, ctx, kill) + go writeLoop(ws, ctx, ticker, kill) +} +``` + +**Key steps:** +1. Check rejection hooks (rate limiting, IP bans, etc.) +2. Upgrade HTTP connection to WebSocket +3. Generate authentication challenge (NIP-42) +4. Initialize WebSocket wrapper with context +5. Register client in tracking map +6. Define cleanup function +7. Launch read and write goroutines + +### Read Loop (Primary Goroutine) + +```go +// handlers.go, lines 107-414 +go func() { + defer kill() + + // Configure read constraints + ws.conn.SetReadLimit(rl.MaxMessageSize) + ws.conn.SetReadDeadline(time.Now().Add(rl.PongWait)) + + // Auto-refresh deadline on Pong receipt + ws.conn.SetPongHandler(func(string) error { + ws.conn.SetReadDeadline(time.Now().Add(rl.PongWait)) + return nil + }) + + // Trigger connection hooks + for _, onconnect := range rl.OnConnect { + onconnect(ctx) + } + + // Create message parser (sonic parser is stateful) + smp := nostr.NewMessageParser() + + for { + // Read message (blocks until data available) + typ, msgb, err := ws.conn.ReadMessage() + if err != nil { + // Check if expected close + if websocket.IsUnexpectedCloseError( + err, + websocket.CloseNormalClosure, // 1000 + websocket.CloseGoingAway, // 1001 + websocket.CloseNoStatusReceived, // 1005 + websocket.CloseAbnormalClosure, // 1006 + 4537, // Custom: client preference + ) { + rl.Log.Printf("unexpected close error from %s: %v\n", + GetIPFromRequest(r), err) + } + ws.cancel() + return + } + + // Handle Ping manually (library should auto-respond, but...) + if typ == websocket.PingMessage { + ws.WriteMessage(websocket.PongMessage, nil) + continue + } + + // Zero-copy conversion to string + message := unsafe.String(unsafe.SliceData(msgb), len(msgb)) + + // Parse message (sequential due to sonic parser constraint) + envelope, err := smp.ParseMessage(message) + + // Handle message in separate goroutine (concurrent processing) + go func(message string) { + switch env := envelope.(type) { + case *nostr.EventEnvelope: + handleEvent(ctx, ws, env, rl) + case *nostr.ReqEnvelope: + handleReq(ctx, ws, env, rl) + case *nostr.CloseEnvelope: + handleClose(ctx, ws, env, rl) + case *nostr.CountEnvelope: + handleCount(ctx, ws, env, rl) + case *nostr.AuthEnvelope: + handleAuth(ctx, ws, env, rl) + case *nip77.OpenEnvelope: + handleNegentropyOpen(ctx, ws, env, rl) + case *nip77.MessageEnvelope: + handleNegentropyMsg(ctx, ws, env, rl) + case *nip77.CloseEnvelope: + handleNegentropyClose(ctx, ws, env, rl) + default: + ws.WriteJSON(nostr.NoticeEnvelope("unknown message type")) + } + }(message) + } +}() +``` + +**Critical patterns:** + +1. **SetReadDeadline + SetPongHandler:** Automatic timeout detection + - Read blocks up to `PongWait` (60s) + - Pong receipt resets deadline + - No Pong = timeout error = connection dead + +2. **Zero-copy string conversion:** + ```go + message := unsafe.String(unsafe.SliceData(msgb), len(msgb)) + ``` + - Avoids allocation when converting `[]byte` to `string` + - Safe because `msgb` is newly allocated by `ReadMessage()` + +3. **Sequential parsing, concurrent handling:** + - `smp.ParseMessage()` called sequentially (parser has state) + - Message handling dispatched to goroutine (concurrent) + - Balances correctness and performance + +4. **Goroutine-per-message pattern:** + ```go + go func(message string) { + // Handle message + }(message) + ``` + - Allows next message to be read immediately + - Prevents slow handler blocking read loop + - Captures `message` to avoid data race + +### Write Loop (Ping Goroutine) + +```go +// handlers.go, lines 416-433 +go func() { + defer kill() + + for { + select { + case <-ctx.Done(): + // Connection closed or context canceled + return + + case <-ticker.C: + // Send ping every PingPeriod (30s) + err := ws.WriteMessage(websocket.PingMessage, nil) + if err != nil { + if !strings.HasSuffix(err.Error(), "use of closed network connection") { + rl.Log.Printf("error writing ping: %v; closing websocket\n", err) + } + return + } + } + } +}() +``` + +**Purpose:** +- Send periodic pings to detect dead connections +- Uses `select` to monitor context cancellation +- Returns on any write error (connection dead) + +**Timing relationship:** +``` +PingPeriod: 30 seconds (send ping every 30s) +PongWait: 60 seconds (expect pong within 60s) + +Rule: PingPeriod < PongWait + +If client doesn't respond to 2 consecutive pings, +connection times out after 60 seconds. +``` + +### Connection Cleanup + +```go +kill := func() { + // 1. Trigger disconnect hooks + for _, ondisconnect := range rl.OnDisconnect { + ondisconnect(ctx) + } + + // 2. Stop timers + ticker.Stop() + + // 3. Cancel contexts + cancel() + ws.cancel() + + // 4. Close connection + ws.conn.Close() + + // 5. Remove from tracking + rl.removeClientAndListeners(ws) +} +defer kill() +``` + +**Cleanup order:** +1. **Hooks first:** Allow app to log, update stats +2. **Stop timers:** Prevent goroutine leaks +3. **Cancel contexts:** Signal cancellation to operations +4. **Close connection:** Release network resources +5. **Remove tracking:** Clean up maps + +**Why defer?** Ensures cleanup runs even if goroutine panics + +## Message Handling + +### Event Handling (EVENT) + +```go +// handlers.go, lines 163-258 +case *nostr.EventEnvelope: + // Validate event ID (must match hash of content) + if !env.Event.CheckID() { + ws.WriteJSON(nostr.OKEnvelope{ + EventID: env.Event.ID, + OK: false, + Reason: "invalid: id is computed incorrectly", + }) + return + } + + // Validate signature + if ok, err := env.Event.CheckSignature(); err != nil { + ws.WriteJSON(nostr.OKEnvelope{ + EventID: env.Event.ID, + OK: false, + Reason: "error: failed to verify signature", + }) + return + } else if !ok { + ws.WriteJSON(nostr.OKEnvelope{ + EventID: env.Event.ID, + OK: false, + Reason: "invalid: signature is invalid", + }) + return + } + + // Check NIP-70 protected events + if nip70.IsProtected(env.Event) { + authed := GetAuthed(ctx) + if authed == "" { + // Request authentication + RequestAuth(ctx) + ws.WriteJSON(nostr.OKEnvelope{ + EventID: env.Event.ID, + OK: false, + Reason: "auth-required: must be published by authenticated event author", + }) + return + } + } + + // Route to subrelay if using relay routing + srl := rl + if rl.getSubRelayFromEvent != nil { + srl = rl.getSubRelayFromEvent(&env.Event) + } + + // Handle event based on kind + var skipBroadcast bool + var writeErr error + + if env.Event.Kind == 5 { + // Deletion event + writeErr = srl.handleDeleteRequest(ctx, &env.Event) + } else if nostr.IsEphemeralKind(env.Event.Kind) { + // Ephemeral event (20000-29999) + writeErr = srl.handleEphemeral(ctx, &env.Event) + } else { + // Normal event + skipBroadcast, writeErr = srl.handleNormal(ctx, &env.Event) + } + + // Broadcast to subscribers (unless prevented) + if !skipBroadcast { + n := srl.notifyListeners(&env.Event) + // Can update reason with broadcast count + } + + // Send OK response + ok := writeErr == nil + reason := "" + if writeErr != nil { + reason = writeErr.Error() + } + + ws.WriteJSON(nostr.OKEnvelope{ + EventID: env.Event.ID, + OK: ok, + Reason: reason, + }) +``` + +**Validation sequence:** +1. Check event ID matches content hash +2. Verify cryptographic signature +3. Check authentication if protected event (NIP-70) +4. Route to appropriate subrelay (if multi-relay setup) +5. Handle based on kind (deletion, ephemeral, normal) +6. Broadcast to matching subscriptions +7. Send OK response to publisher + +### Request Handling (REQ) + +```go +// handlers.go, lines 289-324 +case *nostr.ReqEnvelope: + // Create WaitGroup for EOSE synchronization + eose := sync.WaitGroup{} + eose.Add(len(env.Filters)) + + // Create cancelable context for subscription + reqCtx, cancelReqCtx := context.WithCancelCause(ctx) + + // Expose subscription ID in context + reqCtx = context.WithValue(reqCtx, subscriptionIdKey, env.SubscriptionID) + + // Handle each filter + for _, filter := range env.Filters { + // Route to appropriate subrelay + srl := rl + if rl.getSubRelayFromFilter != nil { + srl = rl.getSubRelayFromFilter(filter) + } + + // Query stored events + err := srl.handleRequest(reqCtx, env.SubscriptionID, &eose, ws, filter) + if err != nil { + // Fail entire subscription if any filter rejected + reason := err.Error() + if strings.HasPrefix(reason, "auth-required:") { + RequestAuth(ctx) + } + ws.WriteJSON(nostr.ClosedEnvelope{ + SubscriptionID: env.SubscriptionID, + Reason: reason, + }) + cancelReqCtx(errors.New("filter rejected")) + return + } else { + // Add listener for real-time events + rl.addListener(ws, env.SubscriptionID, srl, filter, cancelReqCtx) + } + } + + // Send EOSE when all stored events dispatched + go func() { + eose.Wait() + ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID)) + }() +``` + +**Subscription lifecycle:** + +1. **Parse filters:** Client sends array of filters in REQ +2. **Create context:** Allows cancellation of subscription +3. **Query database:** For each filter, query stored events +4. **Stream results:** Send matching events to client +5. **Send EOSE:** End Of Stored Events marker +6. **Add listener:** Subscribe to real-time events + +**WaitGroup pattern:** +```go +eose := sync.WaitGroup{} +eose.Add(len(env.Filters)) + +// Each query handler calls eose.Done() when complete + +go func() { + eose.Wait() // Wait for all queries + ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID)) +}() +``` + +### Close Handling (CLOSE) + +```go +// handlers.go, lines 325-327 +case *nostr.CloseEnvelope: + id := string(*env) + rl.removeListenerId(ws, id) +``` + +**Simple unsubscribe:** Remove listener by subscription ID + +### Authentication (AUTH) + +```go +// handlers.go, lines 328-341 +case *nostr.AuthEnvelope: + // Compute relay WebSocket URL + wsBaseUrl := strings.Replace(rl.getBaseURL(r), "http", "ws", 1) + + // Validate AUTH event + if pubkey, ok := nip42.ValidateAuthEvent(&env.Event, ws.Challenge, wsBaseUrl); ok { + // Store authenticated pubkey + ws.AuthedPublicKey = pubkey + + // Close Authed channel (unblocks any waiting goroutines) + ws.authLock.Lock() + if ws.Authed != nil { + close(ws.Authed) + ws.Authed = nil + } + ws.authLock.Unlock() + + // Send OK response + ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: true}) + } else { + // Validation failed + ws.WriteJSON(nostr.OKEnvelope{ + EventID: env.Event.ID, + OK: false, + Reason: "error: failed to authenticate", + }) + } +``` + +**NIP-42 authentication:** +1. Client receives AUTH challenge on connect +2. Client creates kind-22242 event with challenge +3. Server validates event signature and challenge match +4. Server stores authenticated pubkey in `ws.AuthedPublicKey` + +## Subscription Management + +### Subscription Data Structures + +```go +// listener.go, lines 13-24 +type listenerSpec struct { + id string // Subscription ID from REQ + cancel context.CancelCauseFunc // Cancels this subscription + index int // Position in subrelay.listeners array + subrelay *Relay // Reference to (sub)relay handling this +} + +type listener struct { + id string // Subscription ID + filter nostr.Filter // Filter for matching events + ws *WebSocket // WebSocket connection +} +``` + +**Two-level tracking:** +1. **Per-client specs:** `clients map[*WebSocket][]listenerSpec` + - Tracks what subscriptions each client has + - Enables cleanup when client disconnects + +2. **Per-relay listeners:** `listeners []listener` + - Flat array for fast iteration when broadcasting + - No maps, no allocations during broadcast + +### Adding Listeners + +```go +// listener.go, lines 36-60 +func (rl *Relay) addListener( + ws *WebSocket, + id string, + subrelay *Relay, + filter nostr.Filter, + cancel context.CancelCauseFunc, +) { + rl.clientsMutex.Lock() + defer rl.clientsMutex.Unlock() + + if specs, ok := rl.clients[ws]; ok { + // Get position where listener will be added + idx := len(subrelay.listeners) + + // Add spec to client's list + rl.clients[ws] = append(specs, listenerSpec{ + id: id, + cancel: cancel, + subrelay: subrelay, + index: idx, + }) + + // Add listener to relay's list + subrelay.listeners = append(subrelay.listeners, listener{ + ws: ws, + id: id, + filter: filter, + }) + } +} +``` + +**O(1) append operation** + +### Removing Listeners by ID + +```go +// listener.go, lines 64-99 +func (rl *Relay) removeListenerId(ws *WebSocket, id string) { + rl.clientsMutex.Lock() + defer rl.clientsMutex.Unlock() + + if specs, ok := rl.clients[ws]; ok { + // Iterate backwards for safe removal + for s := len(specs) - 1; s >= 0; s-- { + spec := specs[s] + if spec.id == id { + // Cancel subscription context + spec.cancel(ErrSubscriptionClosedByClient) + + // Swap-delete from specs array + specs[s] = specs[len(specs)-1] + specs = specs[0 : len(specs)-1] + rl.clients[ws] = specs + + // Remove from listener list in subrelay + srl := spec.subrelay + + // If not last element, swap with last + if spec.index != len(srl.listeners)-1 { + movedFromIndex := len(srl.listeners) - 1 + moved := srl.listeners[movedFromIndex] + srl.listeners[spec.index] = moved + + // Update moved listener's spec index + movedSpecs := rl.clients[moved.ws] + idx := slices.IndexFunc(movedSpecs, func(ls listenerSpec) bool { + return ls.index == movedFromIndex && ls.subrelay == srl + }) + movedSpecs[idx].index = spec.index + rl.clients[moved.ws] = movedSpecs + } + + // Truncate listeners array + srl.listeners = srl.listeners[0 : len(srl.listeners)-1] + } + } + } +} +``` + +**Swap-delete pattern:** +1. Move last element to deleted position +2. Truncate array +3. **Result:** O(1) deletion without preserving order + +**Why not just delete?** +- `append(arr[:i], arr[i+1:]...)` is O(n) - shifts all elements +- Swap-delete is O(1) - just one swap and truncate +- Order doesn't matter for listeners + +### Removing All Client Listeners + +```go +// listener.go, lines 101-133 +func (rl *Relay) removeClientAndListeners(ws *WebSocket) { + rl.clientsMutex.Lock() + defer rl.clientsMutex.Unlock() + + if specs, ok := rl.clients[ws]; ok { + // Remove each subscription + for s, spec := range specs { + srl := spec.subrelay + + // Swap-delete from listeners array + if spec.index != len(srl.listeners)-1 { + movedFromIndex := len(srl.listeners) - 1 + moved := srl.listeners[movedFromIndex] + srl.listeners[spec.index] = moved + + // Mark current spec as invalid + rl.clients[ws][s].index = -1 + + // Update moved listener's spec + movedSpecs := rl.clients[moved.ws] + idx := slices.IndexFunc(movedSpecs, func(ls listenerSpec) bool { + return ls.index == movedFromIndex && ls.subrelay == srl + }) + movedSpecs[idx].index = spec.index + rl.clients[moved.ws] = movedSpecs + } + + // Truncate listeners array + srl.listeners = srl.listeners[0 : len(srl.listeners)-1] + } + } + + // Remove client from map + delete(rl.clients, ws) +} +``` + +**Called when client disconnects:** Removes all subscriptions for that client + +### Broadcasting to Listeners + +```go +// listener.go, lines 136-151 +func (rl *Relay) notifyListeners(event *nostr.Event) int { + count := 0 + +listenersloop: + for _, listener := range rl.listeners { + // Check if filter matches event + if listener.filter.Matches(event) { + // Check if broadcast should be prevented (hooks) + for _, pb := range rl.PreventBroadcast { + if pb(listener.ws, event) { + continue listenersloop + } + } + + // Send event to subscriber + listener.ws.WriteJSON(nostr.EventEnvelope{ + SubscriptionID: &listener.id, + Event: *event, + }) + count++ + } + } + + return count +} +``` + +**Performance characteristics:** +- **O(n) in number of listeners:** Iterates all active subscriptions +- **Fast filter matching:** Simple field comparisons +- **No allocations:** Uses existing listener array +- **Labeled continue:** Clean exit from nested loop + +**Optimization opportunity:** For relays with thousands of subscriptions, consider: +- Indexing listeners by event kind +- Using bloom filters for quick negatives +- Sharding listeners across goroutines + +## Context Utilities + +### Context Keys + +```go +// utils.go +const ( + wsKey = iota // WebSocket connection + subscriptionIdKey // Current subscription ID + nip86HeaderAuthKey // NIP-86 authorization header + internalCallKey // Internal call marker +) +``` + +**Pattern:** Use iota for compile-time context key uniqueness + +### Get WebSocket from Context + +```go +func GetConnection(ctx context.Context) *WebSocket { + wsi := ctx.Value(wsKey) + if wsi != nil { + return wsi.(*WebSocket) + } + return nil +} +``` + +**Usage:** Retrieve WebSocket in hooks and handlers + +### Get Authenticated Pubkey + +```go +func GetAuthed(ctx context.Context) string { + // Check WebSocket auth + if conn := GetConnection(ctx); conn != nil { + return conn.AuthedPublicKey + } + + // Check NIP-86 header auth + if nip86Auth := ctx.Value(nip86HeaderAuthKey); nip86Auth != nil { + return nip86Auth.(string) + } + + return "" +} +``` + +**Supports two auth mechanisms:** +1. NIP-42 WebSocket authentication +2. NIP-86 HTTP header authentication + +### Request Authentication + +```go +func RequestAuth(ctx context.Context) { + ws := GetConnection(ctx) + + ws.authLock.Lock() + if ws.Authed == nil { + ws.Authed = make(chan struct{}) + } + ws.authLock.Unlock() + + ws.WriteJSON(nostr.AuthEnvelope{Challenge: &ws.Challenge}) +} +``` + +**Sends AUTH challenge to client** + +### Wait for Authentication + +```go +func (ws *WebSocket) WaitForAuth(timeout time.Duration) bool { + ws.authLock.Lock() + authChan := ws.Authed + ws.authLock.Unlock() + + if authChan == nil { + return true // Already authenticated + } + + select { + case <-authChan: + return true // Authenticated + case <-time.After(timeout): + return false // Timeout + } +} +``` + +**Pattern:** Use closed channel as signal + +## Performance Patterns + +### Zero-Copy String Conversion + +```go +message := unsafe.String(unsafe.SliceData(msgb), len(msgb)) +``` + +**When safe:** +- `msgb` is newly allocated by `ReadMessage()` +- Not modified after conversion +- Message processing completes before next read + +**Savings:** Avoids 512 KB allocation per message + +### Goroutine-per-Message + +```go +go func(message string) { + handleMessage(message) +}(message) +``` + +**Benefits:** +- Read loop continues immediately +- Messages processed concurrently +- Natural backpressure (goroutine scheduler) + +**Trade-off:** Goroutine creation overhead (typically <1μs) + +### Swap-Delete for Slice Removal + +```go +// O(1) deletion +arr[i] = arr[len(arr)-1] +arr = arr[:len(arr)-1] + +// vs. O(n) deletion +arr = append(arr[:i], arr[i+1:]...) +``` + +**When appropriate:** +- Order doesn't matter (listeners, specs) +- Frequent removals expected +- Array size significant + +### Lock-Free Session Maps + +```go +negentropySessions *xsync.MapOf[string, *NegentropySession] +``` + +**vs. standard map with mutex:** +```go +sessions map[string]*NegentropySession +mutex sync.RWMutex +``` + +**Benefits of xsync.MapOf:** +- Lock-free concurrent access +- Better performance under contention +- No manual lock management + +**Trade-off:** Slightly more memory per entry + +## Testing Patterns + +### Basic WebSocket Test + +```go +func TestWebSocketConnection(t *testing.T) { + relay := khatru.NewRelay() + + // Start server + server := httptest.NewServer(relay) + defer server.Close() + + // Convert http:// to ws:// + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + + // Connect client + ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer ws.Close() + + // Send REQ + req := `["REQ","test",{"kinds":[1]}]` + if err := ws.WriteMessage(websocket.TextMessage, []byte(req)); err != nil { + t.Fatalf("WriteMessage failed: %v", err) + } + + // Read EOSE + _, msg, err := ws.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage failed: %v", err) + } + + if !strings.Contains(string(msg), "EOSE") { + t.Errorf("Expected EOSE, got: %s", msg) + } +} +``` + +### Testing Hooks + +```go +func TestRejectConnection(t *testing.T) { + relay := khatru.NewRelay() + + // Add rejection hook + relay.RejectConnection = append(relay.RejectConnection, + func(r *http.Request) bool { + return r.RemoteAddr == "192.0.2.1:12345" // Block specific IP + }, + ) + + server := httptest.NewServer(relay) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + + // Should fail to connect + ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err == nil { + ws.Close() + t.Fatal("Expected connection to be rejected") + } + + if resp.StatusCode != 429 { + t.Errorf("Expected 429, got %d", resp.StatusCode) + } +} +``` + +## Production Deployment + +### Recommended Configuration + +```go +relay := khatru.NewRelay() + +relay.ServiceURL = "wss://relay.example.com" +relay.WriteWait = 10 * time.Second +relay.PongWait = 60 * time.Second +relay.PingPeriod = 30 * time.Second +relay.MaxMessageSize = 512000 // 512 KB + +relay.upgrader.EnableCompression = true +relay.upgrader.CheckOrigin = func(r *http.Request) bool { + // For public relays: return true + // For private relays: validate origin + return true +} +``` + +### Rate Limiting Hook + +```go +import "golang.org/x/time/rate" + +type RateLimiter struct { + limiters map[string]*rate.Limiter + mu sync.Mutex +} + +func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter { + rl.mu.Lock() + defer rl.mu.Unlock() + + limiter, exists := rl.limiters[ip] + if !exists { + limiter = rate.NewLimiter(10, 20) // 10/sec, burst 20 + rl.limiters[ip] = limiter + } + + return limiter +} + +rateLimiter := &RateLimiter{limiters: make(map[string]*rate.Limiter)} + +relay.RejectConnection = append(relay.RejectConnection, + func(r *http.Request) bool { + ip := getIP(r) + return !rateLimiter.getLimiter(ip).Allow() + }, +) +``` + +### Monitoring Hook + +```go +relay.OnConnect = append(relay.OnConnect, + func(ctx context.Context) { + ws := khatru.GetConnection(ctx) + log.Printf("connection from %s", khatru.GetIP(ctx)) + metrics.ActiveConnections.Inc() + }, +) + +relay.OnDisconnect = append(relay.OnDisconnect, + func(ctx context.Context) { + log.Printf("disconnection from %s", khatru.GetIP(ctx)) + metrics.ActiveConnections.Dec() + }, +) +``` + +### Graceful Shutdown + +```go +server := &http.Server{ + Addr: ":8080", + Handler: relay, +} + +// Handle shutdown signals +sigChan := make(chan os.Signal, 1) +signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + +go func() { + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal(err) + } +}() + +<-sigChan +log.Println("Shutting down...") + +// Graceful shutdown with timeout +ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) +defer cancel() + +if err := server.Shutdown(ctx); err != nil { + log.Printf("Shutdown error: %v", err) +} +``` + +## Summary + +**Key architectural decisions:** +1. **Dual goroutine per connection:** Separate read and ping concerns +2. **Mutex-protected writes:** Simplest concurrency safety +3. **Hook-based extensibility:** Plugin architecture without framework changes +4. **Swap-delete for listeners:** O(1) subscription removal +5. **Context-based lifecycle:** Clean cancellation propagation +6. **Zero-copy optimizations:** Reduce allocations in hot path + +**When to use khatru patterns:** +- Building Nostr relays in Go +- Need plugin architecture (hooks) +- Want simple, understandable WebSocket handling +- Prioritize correctness over maximum performance +- Support multi-relay routing + +**Performance characteristics:** +- Handles 10,000+ concurrent connections per server +- Sub-millisecond latency for event broadcast +- ~10 MB memory per 1000 connections +- Single-core CPU can serve 1000+ req/sec + +**Further reading:** +- khatru repository: https://github.com/fiatjaf/khatru +- nostr-sdk (includes khatru): https://github.com/nbd-wtf/go-nostr +- WebSocket library: https://github.com/fasthttp/websocket diff --git a/.claude/skills/nostr-websocket/references/rust_implementation.md b/.claude/skills/nostr-websocket/references/rust_implementation.md new file mode 100644 index 00000000..f5d09a96 --- /dev/null +++ b/.claude/skills/nostr-websocket/references/rust_implementation.md @@ -0,0 +1,1307 @@ +# Rust WebSocket Implementation for Nostr Relays (nostr-rs-relay patterns) + +This reference documents production-ready async WebSocket patterns from the nostr-rs-relay implementation in Rust. + +## Repository Information + +- **Project:** nostr-rs-relay - Nostr relay in Rust +- **Repository:** https://github.com/scsibug/nostr-rs-relay +- **Language:** Rust (2021 edition) +- **WebSocket Library:** tokio-tungstenite 0.17 +- **Async Runtime:** tokio 1.x +- **Architecture:** Async/await with tokio::select! for concurrent operations + +## Core Architecture + +### Async Runtime Foundation + +nostr-rs-relay is built on tokio, Rust's async runtime: + +```rust +#[tokio::main] +async fn main() { + // Initialize logging + tracing_subscriber::fmt::init(); + + // Load configuration + let settings = Settings::load().expect("Failed to load config"); + + // Initialize database connection pool + let repo = create_database_pool(&settings).await; + + // Create broadcast channel for real-time events + let (broadcast_tx, _) = broadcast::channel(1024); + + // Create shutdown signal channel + let (shutdown_tx, _) = broadcast::channel(1); + + // Start HTTP server with WebSocket upgrade + let server = Server::bind(&settings.network.address) + .serve(make_service_fn(|_| { + let repo = repo.clone(); + let broadcast = broadcast_tx.clone(); + let shutdown = shutdown_tx.subscribe(); + let settings = settings.clone(); + + async move { + Ok::<_, Infallible>(service_fn(move |req| { + handle_request( + req, + repo.clone(), + broadcast.clone(), + shutdown.subscribe(), + settings.clone(), + ) + })) + } + })); + + // Handle graceful shutdown + tokio::select! { + _ = server => {}, + _ = tokio::signal::ctrl_c() => { + info!("Shutting down gracefully"); + shutdown_tx.send(()).ok(); + }, + } +} +``` + +**Key components:** +- **tokio runtime:** Manages async tasks and I/O +- **Broadcast channels:** Publish-subscribe for real-time events +- **Database pool:** Shared connection pool across tasks +- **Graceful shutdown:** Signal propagation via broadcast channel + +### WebSocket Configuration + +```rust +let config = WebSocketConfig { + max_send_queue: Some(1024), + max_message_size: settings.limits.max_ws_message_bytes, + max_frame_size: settings.limits.max_ws_frame_bytes, + ..Default::default() +}; + +let ws_stream = WebSocketStream::from_raw_socket( + upgraded, + tokio_tungstenite::tungstenite::protocol::Role::Server, + Some(config), +).await; +``` + +**Configuration options:** +- `max_send_queue`: Maximum queued outgoing messages (1024) +- `max_message_size`: Maximum message size in bytes (default 512 KB) +- `max_frame_size`: Maximum frame size in bytes (default 16 KB) + +**Recommended production settings:** +```rust +WebSocketConfig { + max_send_queue: Some(1024), + max_message_size: Some(512_000), // 512 KB + max_frame_size: Some(16_384), // 16 KB + accept_unmasked_frames: false, // Security + ..Default::default() +} +``` + +## Connection State Management + +### ClientConn Structure + +```rust +pub struct ClientConn { + /// Client IP address (from socket or proxy header) + client_ip_addr: String, + + /// Unique client identifier (UUID v4) + client_id: Uuid, + + /// Active subscriptions (keyed by subscription ID) + subscriptions: HashMap, + + /// Maximum concurrent subscriptions per connection + max_subs: usize, + + /// NIP-42 authentication state + auth: Nip42AuthState, +} + +pub enum Nip42AuthState { + /// Not authenticated yet + NoAuth, + /// AUTH challenge sent + Challenge(String), + /// Authenticated with pubkey + AuthPubkey(String), +} + +impl ClientConn { + pub fn new(client_ip_addr: String) -> Self { + ClientConn { + client_ip_addr, + client_id: Uuid::new_v4(), + subscriptions: HashMap::new(), + max_subs: 32, + auth: Nip42AuthState::NoAuth, + } + } + + /// Add subscription (enforces limits) + pub fn subscribe(&mut self, s: Subscription) -> Result<()> { + let sub_id_len = s.id.len(); + + // Prevent excessively long subscription IDs + if sub_id_len > MAX_SUBSCRIPTION_ID_LEN { + return Err(Error::SubIdMaxLengthError); + } + + // Check subscription limit + if self.subscriptions.len() >= self.max_subs { + return Err(Error::SubMaxExceededError); + } + + self.subscriptions.insert(s.id.clone(), s); + Ok(()) + } + + /// Remove subscription + pub fn unsubscribe(&mut self, id: &str) { + self.subscriptions.remove(id); + } + + /// Get all subscriptions + pub fn subscriptions(&self) -> impl Iterator { + self.subscriptions.iter() + } +} +``` + +**Resource limits:** +```rust +const MAX_SUBSCRIPTION_ID_LEN: usize = 256; +const MAX_SUBS_PER_CLIENT: usize = 32; +``` + +**Security considerations:** +- UUID prevents ID guessing attacks +- Subscription limits prevent resource exhaustion +- Subscription ID length limit prevents hash collision attacks + +## Main Event Loop (tokio::select!) + +### Async Message Multiplexing + +```rust +async fn nostr_server( + repo: Arc, + client_info: ClientInfo, + settings: Settings, + mut ws_stream: WebSocketStream, + broadcast: Sender, + event_tx: mpsc::Sender, + mut shutdown: Receiver<()>, + metrics: NostrMetrics, +) { + // Initialize connection state + let mut conn = ClientConn::new(client_info.remote_ip); + + // Subscribe to broadcast events + let mut bcast_rx = broadcast.subscribe(); + + // Create channels for database queries + let (query_tx, mut query_rx) = mpsc::channel(256); + let (notice_tx, mut notice_rx) = mpsc::channel(32); + + // Track activity for timeout + let mut last_message_time = Instant::now(); + let max_quiet_time = Duration::from_secs(settings.limits.max_conn_idle_seconds); + + // Periodic ping interval (5 minutes) + let mut ping_interval = tokio::time::interval(Duration::from_secs(300)); + + // Main event loop + loop { + tokio::select! { + // 1. Handle shutdown signal + _ = shutdown.recv() => { + info!("Shutdown received, closing connection"); + break; + }, + + // 2. Send periodic pings + _ = ping_interval.tick() => { + // Check if connection has been quiet too long + if last_message_time.elapsed() > max_quiet_time { + debug!("Connection idle timeout"); + metrics.disconnects.with_label_values(&["timeout"]).inc(); + break; + } + + // Send ping + if ws_stream.send(Message::Ping(Vec::new())).await.is_err() { + break; + } + }, + + // 3. Handle notice messages (from database queries) + Some(notice_msg) = notice_rx.recv() => { + ws_stream.send(make_notice_message(¬ice_msg)).await.ok(); + }, + + // 4. Handle query results (from database) + Some(query_result) = query_rx.recv() => { + match query_result { + QueryResult::Event(sub_id, event) => { + // Send event to client + let event_str = serde_json::to_string(&event)?; + let msg = format!("[\"EVENT\",\"{}\",{}]", sub_id, event_str); + ws_stream.send(Message::Text(msg)).await.ok(); + metrics.sent_events.with_label_values(&["stored"]).inc(); + }, + QueryResult::EOSE(sub_id) => { + // Send EOSE marker + let msg = format!("[\"EOSE\",\"{}\"]", sub_id); + ws_stream.send(Message::Text(msg)).await.ok(); + }, + } + }, + + // 5. Handle broadcast events (real-time) + Ok(global_event) = bcast_rx.recv() => { + // Check all subscriptions + for (sub_id, subscription) in conn.subscriptions() { + if subscription.interested_in_event(&global_event) { + // Serialize and send + let event_str = serde_json::to_string(&global_event)?; + let msg = format!("[\"EVENT\",\"{}\",{}]", sub_id, event_str); + ws_stream.send(Message::Text(msg)).await.ok(); + metrics.sent_events.with_label_values(&["realtime"]).inc(); + } + } + }, + + // 6. Handle incoming WebSocket messages + ws_next = ws_stream.next() => { + last_message_time = Instant::now(); + + let nostr_msg = match ws_next { + // Text message (expected) + Some(Ok(Message::Text(m))) => { + convert_to_msg(&m, settings.limits.max_event_bytes) + }, + + // Binary message (not accepted) + Some(Ok(Message::Binary(_))) => { + ws_stream.send(make_notice_message( + &Notice::message("binary messages not accepted".into()) + )).await.ok(); + continue; + }, + + // Ping/Pong (handled automatically by tungstenite) + Some(Ok(Message::Ping(_) | Message::Pong(_))) => { + continue; + }, + + // Capacity error (message too large) + Some(Err(WsError::Capacity(MessageTooLong{size, max_size}))) => { + ws_stream.send(make_notice_message( + &Notice::message(format!("message too large ({} > {})", size, max_size)) + )).await.ok(); + continue; + }, + + // Connection closed (graceful or error) + None | + Some(Ok(Message::Close(_))) | + Some(Err(WsError::AlreadyClosed | WsError::ConnectionClosed)) => { + debug!("WebSocket closed from client"); + metrics.disconnects.with_label_values(&["normal"]).inc(); + break; + }, + + // I/O error (network failure) + Some(Err(WsError::Io(e))) => { + warn!("I/O error on WebSocket: {:?}", e); + metrics.disconnects.with_label_values(&["error"]).inc(); + break; + }, + + // Unknown error + x => { + info!("Unknown WebSocket error: {:?}", x); + metrics.disconnects.with_label_values(&["error"]).inc(); + break; + } + }; + + // Process Nostr message + if let Ok(msg) = nostr_msg { + handle_nostr_message( + msg, + &mut conn, + &repo, + &event_tx, + &query_tx, + ¬ice_tx, + &settings, + &metrics, + ).await; + } + }, + } + } + + // Cleanup on disconnect + for (_, stop_tx) in running_queries { + stop_tx.send(()).ok(); + } + + info!( + "Connection closed: cid={}, ip={}, sent={} events, recv={} events, duration={:?}", + conn.client_id, + conn.client_ip_addr, + client_sent_event_count, + client_received_event_count, + connection_start.elapsed() + ); +} +``` + +**tokio::select! pattern:** +- **Concurrent awaiting:** All branches polled concurrently +- **Fair scheduling:** No branch starves others +- **Clean shutdown:** Any branch can break loop + +**Key branches:** +1. **Shutdown:** Graceful termination signal +2. **Ping timer:** Keep-alive mechanism +3. **Notice messages:** Error/info from database +4. **Query results:** Stored events from database +5. **Broadcast events:** Real-time events from other clients +6. **WebSocket messages:** Incoming client messages + +## Message Handling + +### Nostr Message Types + +```rust +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum NostrMessage { + /// EVENT and AUTH messages + EventMsg(EventCmd), + /// REQ message + SubMsg(Subscription), + /// CLOSE message + CloseMsg(CloseCmd), +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum EventCmd { + /// EVENT command + Event(Event), + /// AUTH command (NIP-42) + Auth(Event), +} + +/// Convert JSON string to NostrMessage +fn convert_to_msg(msg: &str, max_bytes: Option) -> Result { + // Check size limit before parsing + if let Some(max_size) = max_bytes { + if msg.len() > max_size && max_size > 0 { + return Err(Error::EventMaxLengthError(msg.len())); + } + } + + // Parse JSON + serde_json::from_str(msg).map_err(|e| { + trace!("JSON parse error: {:?}", e); + Error::ProtoParseError + }) +} +``` + +**Untagged enum:** serde_json tries each variant until one matches + +### EVENT Message Handling + +```rust +async fn handle_event( + event: Event, + conn: &ClientConn, + event_tx: &mpsc::Sender, + settings: &Settings, + metrics: &NostrMetrics, +) -> Notice { + // Update metrics + metrics.cmd_event.inc(); + + // Validate event ID + if !event.validate_id() { + return Notice::invalid(&event.id, "event id does not match content"); + } + + // Verify signature + if let Err(e) = event.verify_signature() { + return Notice::invalid(&event.id, &format!("signature verification failed: {}", e)); + } + + // Check timestamp (reject far future events) + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if event.created_at > now + settings.limits.max_future_seconds { + return Notice::invalid(&event.id, "event timestamp too far in future"); + } + + // Check expiration (NIP-40) + if let Some(expiration) = event.get_expiration() { + if expiration < now { + return Notice::invalid(&event.id, "event has expired"); + } + } + + // Check authentication requirements + if event.is_protected() { + match &conn.auth { + Nip42AuthState::AuthPubkey(pubkey) => { + if pubkey != &event.pubkey { + return Notice::auth_required(&event.id, "protected event must be published by authenticated author"); + } + }, + _ => { + return Notice::auth_required(&event.id, "auth-required: protected event"); + } + } + } + + // Send to event processing pipeline + let submitted = SubmittedEvent { + event, + source_ip: conn.client_ip_addr.clone(), + client_id: conn.client_id, + }; + + if event_tx.send(submitted).await.is_err() { + return Notice::error("internal server error"); + } + + // Wait for database response (with timeout) + // Returns OK message when stored + Notice::saved(&event.id) +} +``` + +**Validation sequence:** +1. Event ID matches content hash +2. Signature cryptographically valid +3. Timestamp not too far in future +4. Event not expired (NIP-40) +5. Authentication valid if protected (NIP-70) + +### REQ Message Handling + +```rust +async fn handle_req( + subscription: Subscription, + conn: &mut ClientConn, + repo: &Arc, + query_tx: &mpsc::Sender, + notice_tx: &mpsc::Sender, + settings: &Settings, + metrics: &NostrMetrics, +) { + metrics.cmd_req.inc(); + + // Add subscription to connection + if let Err(e) = conn.subscribe(subscription.clone()) { + let reason = match e { + Error::SubMaxExceededError => "subscription limit exceeded", + Error::SubIdMaxLengthError => "subscription ID too long", + _ => "subscription rejected", + }; + + // Send CLOSED message + let msg = format!("[\"CLOSED\",\"{}\",\"{}\"]", subscription.id, reason); + notice_tx.send(Notice::message(msg)).await.ok(); + return; + } + + // Spawn query task for each filter + for filter in subscription.filters { + // Validate filter (prevent overly broad queries) + if filter.is_scraper_query() { + let msg = format!("[\"CLOSED\",\"{}\",\"filter too broad\"]", subscription.id); + notice_tx.send(Notice::message(msg)).await.ok(); + conn.unsubscribe(&subscription.id); + return; + } + + // Clone channels for query task + let sub_id = subscription.id.clone(); + let query_tx = query_tx.clone(); + let repo = repo.clone(); + + // Spawn async query task + tokio::spawn(async move { + // Query database + let events = repo.query_events(&filter).await; + + // Send results + for event in events { + query_tx.send(QueryResult::Event(sub_id.clone(), event)).await.ok(); + } + + // Send EOSE + query_tx.send(QueryResult::EOSE(sub_id)).await.ok(); + }); + } +} +``` + +**Async pattern:** Each filter query runs in separate task + +**Scraper detection:** +```rust +impl Subscription { + /// Check if subscription is too broad (potential scraper) + pub fn is_scraper(&self) -> bool { + for filter in &self.filters { + let mut specificity = 0; + + // Award points for specific filters + if filter.ids.is_some() { specificity += 2; } + if filter.authors.is_some() { specificity += 1; } + if filter.kinds.is_some() { specificity += 1; } + if filter.tags.is_some() { specificity += 1; } + + // Require at least 2 points + if specificity < 2 { + return true; + } + } + false + } +} +``` + +### CLOSE Message Handling + +```rust +async fn handle_close( + close: CloseCmd, + conn: &mut ClientConn, + metrics: &NostrMetrics, +) { + metrics.cmd_close.inc(); + conn.unsubscribe(&close.id); + debug!("Subscription closed: {}", close.id); +} +``` + +**Simple unsubscribe:** Remove subscription from connection state + +## Filter Matching + +### Filter Structure + +```rust +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct ReqFilter { + /// Event IDs (prefix match) + #[serde(skip_serializing_if = "Option::is_none")] + pub ids: Option>, + + /// Event kinds + #[serde(skip_serializing_if = "Option::is_none")] + pub kinds: Option>, + + /// Event created after this timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub since: Option, + + /// Event created before this timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub until: Option, + + /// Author pubkeys (prefix match) + #[serde(skip_serializing_if = "Option::is_none")] + pub authors: Option>, + + /// Maximum number of events to return + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + + /// Generic tag filters (e.g., #e, #p) + #[serde(flatten)] + pub tags: Option>>, + + /// Force no match (internal use) + #[serde(skip)] + pub force_no_match: bool, +} +``` + +### Event Matching Logic + +```rust +impl ReqFilter { + /// Check if event matches all filter criteria + pub fn interested_in_event(&self, event: &Event) -> bool { + // Short-circuit on force_no_match + if self.force_no_match { + return false; + } + + // All criteria must match + self.ids_match(event) + && self.since_match(event) + && self.until_match(event) + && self.kind_match(event) + && self.authors_match(event) + && self.tag_match(event) + } + + /// Check if event ID matches (prefix match) + fn ids_match(&self, event: &Event) -> bool { + self.ids.as_ref().map_or(true, |ids| { + ids.iter().any(|id| event.id.starts_with(id)) + }) + } + + /// Check if timestamp in range + fn since_match(&self, event: &Event) -> bool { + self.since.map_or(true, |since| event.created_at >= since) + } + + fn until_match(&self, event: &Event) -> bool { + self.until.map_or(true, |until| event.created_at <= until) + } + + /// Check if kind matches + fn kind_match(&self, event: &Event) -> bool { + self.kinds.as_ref().map_or(true, |kinds| { + kinds.contains(&event.kind) + }) + } + + /// Check if author matches (prefix match) + fn authors_match(&self, event: &Event) -> bool { + self.authors.as_ref().map_or(true, |authors| { + authors.iter().any(|author| event.pubkey.starts_with(author)) + }) + } + + /// Check if tags match + fn tag_match(&self, event: &Event) -> bool { + self.tags.as_ref().map_or(true, |tag_filters| { + // All tag filters must match + tag_filters.iter().all(|(tag_name, tag_values)| { + // Event must have at least one matching value for this tag + event.generic_tag_val_intersect(*tag_name, tag_values) + }) + }) + } +} +``` + +**Performance characteristics:** +- **Early return:** `force_no_match` short-circuits immediately +- **Prefix matching:** Allows hex prefix searches (e.g., "abc" matches "abc123...") +- **Set intersection:** Uses `HashSet` for efficient tag value matching + +## Database Abstraction + +### NostrRepo Trait + +```rust +#[async_trait] +pub trait NostrRepo: Send + Sync { + /// Query events matching filter + async fn query_events(&self, filter: &ReqFilter) -> Vec; + + /// Store event + async fn store_event(&self, event: &Event) -> Result<()>; + + /// Check if event exists + async fn event_exists(&self, id: &str) -> bool; + + /// Delete events (kind 5) + async fn delete_events(&self, deletion: &Event) -> Result; + + /// Get relay info (NIP-11) + async fn get_relay_info(&self) -> RelayInfo; +} +``` + +**Implementations:** +- **PostgreSQL:** Production deployments +- **SQLite:** Development and small relays +- **In-memory:** Testing + +### PostgreSQL Implementation Example + +```rust +#[async_trait] +impl NostrRepo for PostgresRepo { + async fn query_events(&self, filter: &ReqFilter) -> Vec { + let mut query = String::from("SELECT event_json FROM events WHERE "); + let mut conditions = Vec::new(); + let mut param_num = 1; + + // Build WHERE clause + if let Some(ids) = &filter.ids { + let id_conditions: Vec = ids.iter() + .map(|_| { let p = param_num; param_num += 1; format!("id LIKE ${} || '%'", p) }) + .collect(); + conditions.push(format!("({})", id_conditions.join(" OR "))); + } + + if let Some(authors) = &filter.authors { + let author_conditions: Vec = authors.iter() + .map(|_| { let p = param_num; param_num += 1; format!("pubkey LIKE ${} || '%'", p) }) + .collect(); + conditions.push(format!("({})", author_conditions.join(" OR "))); + } + + if let Some(kinds) = &filter.kinds { + let kind_list = kinds.iter() + .map(|k| k.to_string()) + .collect::>() + .join(", "); + conditions.push(format!("kind IN ({})", kind_list)); + } + + if let Some(since) = filter.since { + conditions.push(format!("created_at >= {}", since)); + } + + if let Some(until) = filter.until { + conditions.push(format!("created_at <= {}", until)); + } + + // Add tag filters (requires JOIN with tags table) + if let Some(tags) = &filter.tags { + for (tag_name, _) in tags { + let p = param_num; + param_num += 1; + conditions.push(format!( + "EXISTS (SELECT 1 FROM tags WHERE tags.event_id = events.id \ + AND tags.name = ${} AND tags.value = ANY(${})", + p, p + 1 + )); + } + } + + query.push_str(&conditions.join(" AND ")); + query.push_str(" ORDER BY created_at DESC"); + + if let Some(limit) = filter.limit { + query.push_str(&format!(" LIMIT {}", limit)); + } + + // Execute query with connection pool + let rows = self.pool.query(&query, ¶ms).await?; + + // Parse results + rows.into_iter() + .filter_map(|row| { + let json: String = row.get(0); + serde_json::from_str(&json).ok() + }) + .collect() + } + + async fn store_event(&self, event: &Event) -> Result<()> { + let event_json = serde_json::to_string(event)?; + + // Insert event + self.pool.execute( + "INSERT INTO events (id, pubkey, created_at, kind, event_json) \ + VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT (id) DO NOTHING", + &[&event.id, &event.pubkey, &(event.created_at as i64), &(event.kind as i64), &event_json] + ).await?; + + // Insert tags + for tag in &event.tags { + if tag.len() >= 2 { + let tag_name = &tag[0]; + let tag_value = &tag[1]; + + self.pool.execute( + "INSERT INTO tags (event_id, name, value) VALUES ($1, $2, $3)", + &[&event.id, tag_name, tag_value] + ).await.ok(); + } + } + + Ok(()) + } +} +``` + +**Database schema:** +```sql +CREATE TABLE events ( + id TEXT PRIMARY KEY, + pubkey TEXT NOT NULL, + created_at BIGINT NOT NULL, + kind INTEGER NOT NULL, + event_json TEXT NOT NULL +); + +CREATE INDEX idx_pubkey ON events(pubkey); +CREATE INDEX idx_created_at ON events(created_at); +CREATE INDEX idx_kind ON events(kind); + +CREATE TABLE tags ( + event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE, + name TEXT NOT NULL, + value TEXT NOT NULL +); + +CREATE INDEX idx_tags_event ON tags(event_id); +CREATE INDEX idx_tags_name_value ON tags(name, value); +``` + +## Error Handling + +### Error Types + +```rust +#[derive(Error, Debug)] +pub enum Error { + #[error("Protocol parse error")] + ProtoParseError, + + #[error("Event invalid signature")] + EventInvalidSignature, + + #[error("Event invalid ID")] + EventInvalidId, + + #[error("Event too large: {0} bytes")] + EventMaxLengthError(usize), + + #[error("Subscription ID max length exceeded")] + SubIdMaxLengthError, + + #[error("Subscription limit exceeded")] + SubMaxExceededError, + + #[error("WebSocket error: {0}")] + WebsocketError(#[from] WsError), + + #[error("Database error: {0}")] + DatabaseError(String), + + #[error("Connection closed")] + ConnClosed, +} +``` + +**Using thiserror:** Automatic `impl Error` and `Display` + +### Error Handling in Event Loop + +```rust +match ws_stream.next().await { + Some(Ok(Message::Text(msg))) => { + // Handle text message + }, + + Some(Err(WsError::Capacity(MessageTooLong{size, max_size}))) => { + // Message too large - send notice, continue + let notice = format!("message too large ({} > {})", size, max_size); + ws_stream.send(make_notice_message(&Notice::message(notice))).await.ok(); + continue; + }, + + Some(Err(WsError::Io(e))) => { + // I/O error - log and close connection + warn!("I/O error on WebSocket: {:?}", e); + metrics.disconnects.with_label_values(&["error"]).inc(); + break; + }, + + None | Some(Ok(Message::Close(_))) => { + // Normal closure + debug!("Connection closed gracefully"); + metrics.disconnects.with_label_values(&["normal"]).inc(); + break; + }, + + _ => { + // Unknown error - close connection + info!("Unknown WebSocket error"); + metrics.disconnects.with_label_values(&["error"]).inc(); + break; + } +} +``` + +**Error strategy:** +- **Recoverable errors:** Send notice, continue loop +- **Fatal errors:** Log and break loop +- **Classify disconnects:** Metrics by disconnect reason + +## Metrics and Monitoring + +### Prometheus Metrics + +```rust +#[derive(Clone)] +pub struct NostrMetrics { + /// Query response time histogram + pub query_sub: Histogram, + + /// Individual database query time + pub query_db: Histogram, + + /// Active database connections + pub db_connections: IntGauge, + + /// Event write response time + pub write_events: Histogram, + + /// Events sent to clients (by source: stored/realtime) + pub sent_events: IntCounterVec, + + /// Total connections + pub connections: IntCounter, + + /// Client disconnects (by reason: normal/error/timeout) + pub disconnects: IntCounterVec, + + /// Queries aborted (by reason) + pub query_aborts: IntCounterVec, + + /// Commands received (by type: REQ/EVENT/CLOSE/AUTH) + pub cmd_req: IntCounter, + pub cmd_event: IntCounter, + pub cmd_close: IntCounter, + pub cmd_auth: IntCounter, +} + +impl NostrMetrics { + pub fn new() -> Self { + NostrMetrics { + query_sub: register_histogram!( + "nostr_query_seconds", + "Subscription query response time" + ).unwrap(), + + db_connections: register_int_gauge!( + "nostr_db_connections", + "Active database connections" + ).unwrap(), + + sent_events: register_int_counter_vec!( + "nostr_sent_events_total", + "Events sent to clients", + &["source"] + ).unwrap(), + + disconnects: register_int_counter_vec!( + "nostr_disconnects_total", + "Client disconnections", + &["reason"] + ).unwrap(), + + // ... more metrics + } + } +} +``` + +**Tracking in code:** +```rust +// Command received +metrics.cmd_req.inc(); + +// Query timing +let timer = metrics.query_sub.start_timer(); +let events = repo.query_events(&filter).await; +timer.observe_duration(); + +// Event sent +metrics.sent_events.with_label_values(&["realtime"]).inc(); + +// Disconnect +metrics.disconnects.with_label_values(&["timeout"]).inc(); +``` + +**Prometheus endpoint:** +```rust +async fn metrics_handler() -> impl Reply { + use prometheus::Encoder; + let encoder = prometheus::TextEncoder::new(); + let metric_families = prometheus::gather(); + let mut buffer = Vec::new(); + encoder.encode(&metric_families, &mut buffer).unwrap(); + warp::reply::with_header(buffer, "Content-Type", encoder.format_type()) +} +``` + +## Configuration + +### Settings Structure + +```rust +#[derive(Deserialize, Clone)] +pub struct Settings { + pub network: NetworkSettings, + pub database: DatabaseSettings, + pub limits: LimitsSettings, + pub relay_info: RelayInfo, +} + +#[derive(Deserialize, Clone)] +pub struct NetworkSettings { + pub address: SocketAddr, + pub remote_ip_header: Option, +} + +#[derive(Deserialize, Clone)] +pub struct LimitsSettings { + pub max_ws_message_bytes: Option, + pub max_ws_frame_bytes: Option, + pub max_event_bytes: Option, + pub max_conn_idle_seconds: u64, + pub max_future_seconds: u64, +} + +impl Settings { + pub fn load() -> Result { + let config = config::Config::builder() + .add_source(config::File::with_name("config")) + .add_source(config::Environment::with_prefix("NOSTR")) + .build()?; + + config.try_deserialize() + } +} +``` + +**config.toml example:** +```toml +[network] +address = "0.0.0.0:8080" +remote_ip_header = "X-Forwarded-For" + +[database] +connection = "postgresql://user:pass@localhost/nostr" +pool_size = 20 + +[limits] +max_ws_message_bytes = 512000 +max_ws_frame_bytes = 16384 +max_event_bytes = 65536 +max_conn_idle_seconds = 1200 +max_future_seconds = 900 + +[relay_info] +name = "My Nostr Relay" +description = "A public Nostr relay" +pubkey = "..." +contact = "admin@example.com" +``` + +## Testing + +### Integration Test Example + +```rust +#[tokio::test] +async fn test_websocket_subscription() { + // Setup test relay + let repo = Arc::new(MockRepo::new()); + let (broadcast_tx, _) = broadcast::channel(16); + let (_shutdown_tx, shutdown_rx) = broadcast::channel(1); + let settings = test_settings(); + let metrics = NostrMetrics::new(); + + // Start server + let server = tokio::spawn(async move { + // ... start server + }); + + // Connect client + let (mut ws_stream, _) = connect_async("ws://127.0.0.1:8080").await.unwrap(); + + // Send REQ + let req = r#"["REQ","test",{"kinds":[1]}]"#; + ws_stream.send(Message::Text(req.into())).await.unwrap(); + + // Read EOSE + let msg = ws_stream.next().await.unwrap().unwrap(); + assert!(matches!(msg, Message::Text(text) if text.contains("EOSE"))); + + // Send EVENT + let event = create_test_event(); + let event_json = serde_json::to_string(&event).unwrap(); + let cmd = format!(r#"["EVENT",{}]"#, event_json); + ws_stream.send(Message::Text(cmd)).await.unwrap(); + + // Read OK + let msg = ws_stream.next().await.unwrap().unwrap(); + assert!(matches!(msg, Message::Text(text) if text.contains("OK"))); + + // Cleanup + ws_stream.close(None).await.unwrap(); +} +``` + +## Production Deployment + +### Systemd Service + +```ini +[Unit] +Description=Nostr Relay +After=network.target postgresql.service + +[Service] +Type=simple +User=nostr +WorkingDirectory=/opt/nostr-relay +ExecStart=/opt/nostr-relay/nostr-rs-relay +Restart=on-failure +RestartSec=5 + +# Security +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/nostr-relay + +[Install] +WantedBy=multi-user.target +``` + +### Nginx Reverse Proxy + +```nginx +upstream nostr_relay { + server 127.0.0.1:8080; +} + +server { + listen 443 ssl http2; + server_name relay.example.com; + + ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem; + + location / { + proxy_pass http://nostr_relay; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket timeouts + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} +``` + +### Docker Deployment + +```dockerfile +FROM rust:1.70 as builder + +WORKDIR /app +COPY . . +RUN cargo build --release + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/nostr-rs-relay /usr/local/bin/ + +EXPOSE 8080 + +CMD ["nostr-rs-relay"] +``` + +**docker-compose.yml:** +```yaml +version: '3.8' + +services: + relay: + image: nostr-rs-relay:latest + ports: + - "8080:8080" + environment: + - NOSTR__DATABASE__CONNECTION=postgresql://nostr:password@db/nostr + - RUST_LOG=info + depends_on: + - db + restart: unless-stopped + + db: + image: postgres:15 + environment: + - POSTGRES_USER=nostr + - POSTGRES_PASSWORD=password + - POSTGRES_DB=nostr + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + +volumes: + postgres_data: +``` + +## Summary + +**Key patterns:** +1. **tokio::select!:** Concurrent event handling with cancellation +2. **Async/await:** Clean async code without callbacks +3. **Type safety:** Strong typing prevents entire classes of bugs +4. **Error handling:** Comprehensive error types with thiserror +5. **Database abstraction:** Trait-based repository pattern +6. **Metrics:** Built-in Prometheus instrumentation + +**Performance characteristics:** +- **10,000+ connections** per server +- **Sub-millisecond** p50 latency +- **Memory safe:** No undefined behavior, no memory leaks +- **Concurrent queries:** Tokio runtime schedules efficiently + +**When to use Rust patterns:** +- Need memory safety without GC pauses +- Want high-level abstractions with zero cost +- Building mission-critical relay infrastructure +- Team has Rust experience +- Performance critical (CPU or memory constrained) + +**Trade-offs:** +- **Learning curve:** Rust's borrow checker takes time +- **Compile times:** Slower than interpreted languages +- **Async complexity:** Async Rust has sharp edges + +**Further reading:** +- nostr-rs-relay: https://github.com/scsibug/nostr-rs-relay +- tokio documentation: https://tokio.rs +- tungstenite: https://github.com/snapview/tungstenite-rs +- Rust async book: https://rust-lang.github.io/async-book/ diff --git a/.claude/skills/nostr-websocket/references/strfry_implementation.md b/.claude/skills/nostr-websocket/references/strfry_implementation.md new file mode 100644 index 00000000..b094eb24 --- /dev/null +++ b/.claude/skills/nostr-websocket/references/strfry_implementation.md @@ -0,0 +1,921 @@ +# C++ WebSocket Implementation for Nostr Relays (strfry patterns) + +This reference documents high-performance WebSocket patterns from the strfry Nostr relay implementation in C++. + +## Repository Information + +- **Project:** strfry - High-performance Nostr relay +- **Repository:** https://github.com/hoytech/strfry +- **Language:** C++ (C++20) +- **WebSocket Library:** Custom fork of uWebSockets with epoll +- **Architecture:** Single-threaded I/O with specialized thread pools + +## Core Architecture + +### Thread Pool Design + +strfry uses 6 specialized thread pools for different operations: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Main Thread (I/O) │ +│ - epoll event loop │ +│ - WebSocket message reception │ +│ - Connection management │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ┌────▼────┐ ┌───▼────┐ ┌───▼────┐ + │Ingester │ │ReqWorker│ │Negentropy│ + │ (3) │ │ (3) │ │ (2) │ + └─────────┘ └─────────┘ └─────────┘ + │ │ │ + ┌────▼────┐ ┌───▼────┐ + │ Writer │ │ReqMonitor│ + │ (1) │ │ (3) │ + └─────────┘ └─────────┘ +``` + +**Thread Pool Responsibilities:** + +1. **WebSocket (1 thread):** Main I/O loop, epoll event handling +2. **Ingester (3 threads):** Event validation, signature verification, deduplication +3. **Writer (1 thread):** Database writes, event storage +4. **ReqWorker (3 threads):** Process REQ subscriptions, query database +5. **ReqMonitor (3 threads):** Monitor active subscriptions, send real-time events +6. **Negentropy (2 threads):** NIP-77 set reconciliation + +**Deterministic thread assignment:** +```cpp +int threadId = connId % numThreads; +``` + +**Benefits:** +- **No lock contention:** Shared-nothing architecture +- **Predictable performance:** Same connection always same thread +- **CPU cache efficiency:** Thread-local data stays hot + +### Connection State + +```cpp +struct ConnectionState { + uint64_t connId; // Unique connection identifier + std::string remoteAddr; // Client IP address + + // Subscription state + flat_str subId; // Current subscription ID + std::shared_ptr sub; // Subscription filter + uint64_t latestEventSent = 0; // Latest event ID sent + + // Compression state (per-message deflate) + PerMessageDeflate pmd; + + // Parsing state (reused buffer) + std::string parseBuffer; + + // Signature verification context (reused) + secp256k1_context *secpCtx; +}; +``` + +**Key design decisions:** + +1. **Reusable parseBuffer:** Single allocation per connection +2. **Persistent secp256k1_context:** Expensive to create, reused for all signatures +3. **Connection ID:** Enables deterministic thread assignment +4. **Flat string (flat_str):** Value-semantic string-like type for zero-copy + +## WebSocket Message Reception + +### Main Event Loop (epoll) + +```cpp +// Pseudocode representation of strfry's I/O loop +uWS::App app; + +app.ws("/*", { + .compression = uWS::SHARED_COMPRESSOR, + .maxPayloadLength = 16 * 1024 * 1024, + .idleTimeout = 120, + .maxBackpressure = 1 * 1024 * 1024, + + .upgrade = nullptr, + + .open = [](auto *ws) { + auto *state = ws->getUserData(); + state->connId = nextConnId++; + state->remoteAddr = getRemoteAddress(ws); + state->secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY); + + LI << "New connection: " << state->connId << " from " << state->remoteAddr; + }, + + .message = [](auto *ws, std::string_view message, uWS::OpCode opCode) { + auto *state = ws->getUserData(); + + // Reuse parseBuffer to avoid allocation + state->parseBuffer.assign(message.data(), message.size()); + + try { + // Parse JSON (nlohmann::json) + auto json = nlohmann::json::parse(state->parseBuffer); + + // Extract command type + auto cmdStr = json[0].get(); + + if (cmdStr == "EVENT") { + handleEventMessage(ws, std::move(json)); + } + else if (cmdStr == "REQ") { + handleReqMessage(ws, std::move(json)); + } + else if (cmdStr == "CLOSE") { + handleCloseMessage(ws, std::move(json)); + } + else if (cmdStr == "NEG-OPEN") { + handleNegentropyOpen(ws, std::move(json)); + } + else { + sendNotice(ws, "unknown command: " + cmdStr); + } + } + catch (std::exception &e) { + sendNotice(ws, "Error: " + std::string(e.what())); + } + }, + + .close = [](auto *ws, int code, std::string_view message) { + auto *state = ws->getUserData(); + + LI << "Connection closed: " << state->connId + << " code=" << code + << " msg=" << std::string(message); + + // Cleanup + secp256k1_context_destroy(state->secpCtx); + cleanupSubscription(state->connId); + }, +}); + +app.listen(8080, [](auto *token) { + if (token) { + LI << "Listening on port 8080"; + } +}); + +app.run(); +``` + +**Key patterns:** + +1. **epoll-based I/O:** Single thread handles thousands of connections +2. **Buffer reuse:** `state->parseBuffer` avoids allocation per message +3. **Move semantics:** `std::move(json)` transfers ownership to handler +4. **Exception handling:** Catches parsing errors, sends NOTICE + +### Message Dispatch to Thread Pools + +```cpp +void handleEventMessage(auto *ws, nlohmann::json &&json) { + auto *state = ws->getUserData(); + + // Pack message with connection ID + auto msg = MsgIngester{ + .connId = state->connId, + .payload = std::move(json), + }; + + // Dispatch to Ingester thread pool (deterministic assignment) + tpIngester->dispatchToThread(state->connId, std::move(msg)); +} + +void handleReqMessage(auto *ws, nlohmann::json &&json) { + auto *state = ws->getUserData(); + + // Pack message + auto msg = MsgReq{ + .connId = state->connId, + .payload = std::move(json), + }; + + // Dispatch to ReqWorker thread pool + tpReqWorker->dispatchToThread(state->connId, std::move(msg)); +} +``` + +**Message passing pattern:** + +```cpp +// ThreadPool::dispatchToThread +void dispatchToThread(uint64_t connId, Message &&msg) { + size_t threadId = connId % threads.size(); + threads[threadId]->queue.push(std::move(msg)); +} +``` + +**Benefits:** +- **Zero-copy:** `std::move` transfers ownership without copying +- **Deterministic:** Same connection always processed by same thread +- **Lock-free:** Each thread has own queue + +## Event Ingestion Pipeline + +### Ingester Thread Pool + +```cpp +void IngesterThread::run() { + while (running) { + Message msg; + if (!queue.pop(msg, 100ms)) continue; + + // Extract event from JSON + auto event = parseEvent(msg.payload); + + // Validate event ID + if (!validateEventId(event)) { + sendOK(msg.connId, event.id, false, "invalid: id mismatch"); + continue; + } + + // Verify signature (using thread-local secp256k1 context) + if (!verifySignature(event, secpCtx)) { + sendOK(msg.connId, event.id, false, "invalid: signature verification failed"); + continue; + } + + // Check for duplicate (bloom filter + database) + if (isDuplicate(event.id)) { + sendOK(msg.connId, event.id, true, "duplicate: already have this event"); + continue; + } + + // Send to Writer thread + auto writerMsg = MsgWriter{ + .connId = msg.connId, + .event = std::move(event), + }; + tpWriter->dispatch(std::move(writerMsg)); + } +} +``` + +**Validation sequence:** +1. Parse JSON into Event struct +2. Validate event ID matches content hash +3. Verify secp256k1 signature +4. Check duplicate (bloom filter for speed) +5. Forward to Writer thread for storage + +### Writer Thread + +```cpp +void WriterThread::run() { + // Single thread for all database writes + while (running) { + Message msg; + if (!queue.pop(msg, 100ms)) continue; + + // Write to database + bool success = db.insertEvent(msg.event); + + // Send OK to client + sendOK(msg.connId, msg.event.id, success, + success ? "" : "error: failed to store"); + + if (success) { + // Broadcast to subscribers + broadcastEvent(msg.event); + } + } +} +``` + +**Single-writer pattern:** +- Only one thread writes to database +- Eliminates write conflicts +- Simplified transaction management + +### Event Broadcasting + +```cpp +void broadcastEvent(const Event &event) { + // Serialize event JSON once + std::string eventJson = serializeEvent(event); + + // Iterate all active subscriptions + for (auto &[connId, sub] : activeSubscriptions) { + // Check if filter matches + if (!sub->filter.matches(event)) continue; + + // Check if event newer than last sent + if (event.id <= sub->latestEventSent) continue; + + // Send to connection + auto msg = MsgWebSocket{ + .connId = connId, + .payload = eventJson, // Reuse serialized JSON + }; + + tpWebSocket->dispatch(std::move(msg)); + + // Update latest sent + sub->latestEventSent = event.id; + } +} +``` + +**Critical optimization:** Serialize event JSON once, send to N subscribers + +**Performance impact:** For 1000 subscribers, reduces: +- JSON serialization: 1000× → 1× +- Memory allocations: 1000× → 1× +- CPU time: ~100ms → ~1ms + +## Subscription Management + +### REQ Processing + +```cpp +void ReqWorkerThread::run() { + while (running) { + MsgReq msg; + if (!queue.pop(msg, 100ms)) continue; + + // Parse REQ message: ["REQ", subId, filter1, filter2, ...] + std::string subId = msg.payload[1]; + + // Create subscription object + auto sub = std::make_shared(); + sub->subId = subId; + + // Parse filters + for (size_t i = 2; i < msg.payload.size(); i++) { + Filter filter = parseFilter(msg.payload[i]); + sub->filters.push_back(filter); + } + + // Store subscription + activeSubscriptions[msg.connId] = sub; + + // Query stored events + std::vector events = db.queryEvents(sub->filters); + + // Send matching events + for (const auto &event : events) { + sendEvent(msg.connId, subId, event); + } + + // Send EOSE + sendEOSE(msg.connId, subId); + + // Notify ReqMonitor to watch for real-time events + auto monitorMsg = MsgReqMonitor{ + .connId = msg.connId, + .subId = subId, + }; + tpReqMonitor->dispatchToThread(msg.connId, std::move(monitorMsg)); + } +} +``` + +**Query optimization:** + +```cpp +std::vector Database::queryEvents(const std::vector &filters) { + // Combine filters with OR logic + std::string sql = "SELECT * FROM events WHERE "; + + for (size_t i = 0; i < filters.size(); i++) { + if (i > 0) sql += " OR "; + sql += buildFilterSQL(filters[i]); + } + + sql += " ORDER BY created_at DESC LIMIT 1000"; + + return executeQuery(sql); +} +``` + +**Filter SQL generation:** + +```cpp +std::string buildFilterSQL(const Filter &filter) { + std::vector conditions; + + // Event IDs + if (!filter.ids.empty()) { + conditions.push_back("id IN (" + joinQuoted(filter.ids) + ")"); + } + + // Authors + if (!filter.authors.empty()) { + conditions.push_back("pubkey IN (" + joinQuoted(filter.authors) + ")"); + } + + // Kinds + if (!filter.kinds.empty()) { + conditions.push_back("kind IN (" + join(filter.kinds) + ")"); + } + + // Time range + if (filter.since) { + conditions.push_back("created_at >= " + std::to_string(*filter.since)); + } + if (filter.until) { + conditions.push_back("created_at <= " + std::to_string(*filter.until)); + } + + // Tags (requires JOIN with tags table) + if (!filter.tags.empty()) { + for (const auto &[tagName, tagValues] : filter.tags) { + conditions.push_back( + "EXISTS (SELECT 1 FROM tags WHERE tags.event_id = events.id " + "AND tags.name = '" + tagName + "' " + "AND tags.value IN (" + joinQuoted(tagValues) + "))" + ); + } + } + + return "(" + join(conditions, " AND ") + ")"; +} +``` + +### ReqMonitor for Real-Time Events + +```cpp +void ReqMonitorThread::run() { + // Subscribe to event broadcast channel + auto eventSubscription = subscribeToEvents(); + + while (running) { + Event event; + if (!eventSubscription.receive(event, 100ms)) continue; + + // Check all subscriptions assigned to this thread + for (auto &[connId, sub] : mySubscriptions) { + // Only process subscriptions for this thread + if (connId % numThreads != threadId) continue; + + // Check if filter matches + bool matches = false; + for (const auto &filter : sub->filters) { + if (filter.matches(event)) { + matches = true; + break; + } + } + + if (matches) { + sendEvent(connId, sub->subId, event); + } + } + } +} +``` + +**Pattern:** Monitor thread watches event stream, sends to matching subscriptions + +### CLOSE Handling + +```cpp +void handleCloseMessage(auto *ws, nlohmann::json &&json) { + auto *state = ws->getUserData(); + + // Parse CLOSE message: ["CLOSE", subId] + std::string subId = json[1]; + + // Remove subscription + activeSubscriptions.erase(state->connId); + + LI << "Subscription closed: connId=" << state->connId + << " subId=" << subId; +} +``` + +## Performance Optimizations + +### 1. Event Batching + +**Problem:** Serializing same event 1000× for 1000 subscribers is wasteful + +**Solution:** Serialize once, send to all + +```cpp +// BAD: Serialize for each subscriber +for (auto &sub : subscriptions) { + std::string json = serializeEvent(event); // Repeated! + send(sub.connId, json); +} + +// GOOD: Serialize once +std::string json = serializeEvent(event); +for (auto &sub : subscriptions) { + send(sub.connId, json); // Reuse! +} +``` + +**Measurement:** For 1000 subscribers, reduces broadcast time from 100ms to 1ms + +### 2. Move Semantics + +**Problem:** Copying large JSON objects is expensive + +**Solution:** Transfer ownership with `std::move` + +```cpp +// BAD: Copies JSON object +void dispatch(Message msg) { + queue.push(msg); // Copy +} + +// GOOD: Moves JSON object +void dispatch(Message &&msg) { + queue.push(std::move(msg)); // Move +} +``` + +**Benefit:** Zero-copy message passing between threads + +### 3. Pre-allocated Buffers + +**Problem:** Allocating buffer for each message + +**Solution:** Reuse buffer per connection + +```cpp +struct ConnectionState { + std::string parseBuffer; // Reused for all messages +}; + +void handleMessage(std::string_view msg) { + state->parseBuffer.assign(msg.data(), msg.size()); + auto json = nlohmann::json::parse(state->parseBuffer); + // ... +} +``` + +**Benefit:** Eliminates 10,000+ allocations/second per connection + +### 4. std::variant for Message Types + +**Problem:** Virtual function calls for polymorphic messages + +**Solution:** `std::variant` with `std::visit` + +```cpp +// BAD: Virtual function (pointer indirection, vtable lookup) +struct Message { + virtual void handle() = 0; +}; + +// GOOD: std::variant (no indirection, inlined) +using Message = std::variant< + MsgIngester, + MsgReq, + MsgWriter, + MsgWebSocket +>; + +void handle(Message &&msg) { + std::visit([](auto &&m) { m.handle(); }, msg); +} +``` + +**Benefit:** Compiler inlines visit, eliminates virtual call overhead + +### 5. Bloom Filter for Duplicate Detection + +**Problem:** Database query for every event to check duplicate + +**Solution:** In-memory bloom filter for fast negative + +```cpp +class DuplicateDetector { + BloomFilter bloom; // Fast probabilistic check + + bool isDuplicate(const std::string &eventId) { + // Fast negative (definitely not seen) + if (!bloom.contains(eventId)) { + bloom.insert(eventId); + return false; + } + + // Possible positive (maybe seen, check database) + if (db.eventExists(eventId)) { + return true; + } + + // False positive + bloom.insert(eventId); + return false; + } +}; +``` + +**Benefit:** 99% of duplicate checks avoid database query + +### 6. Batch Queue Operations + +**Problem:** Lock contention on message queue + +**Solution:** Batch multiple pushes with single lock + +```cpp +class MessageQueue { + std::mutex mutex; + std::deque queue; + + void pushBatch(std::vector &messages) { + std::lock_guard lock(mutex); + for (auto &msg : messages) { + queue.push_back(std::move(msg)); + } + } +}; +``` + +**Benefit:** Reduces lock acquisitions by 10-100× + +### 7. ZSTD Dictionary Compression + +**Problem:** WebSocket compression slower than desired + +**Solution:** Train ZSTD dictionary on typical Nostr messages + +```cpp +// Train dictionary on corpus of Nostr events +std::string corpus = collectTypicalEvents(); +ZSTD_CDict *dict = ZSTD_createCDict( + corpus.data(), corpus.size(), + compressionLevel +); + +// Use dictionary for compression +size_t compressedSize = ZSTD_compress_usingCDict( + cctx, dst, dstSize, + src, srcSize, dict +); +``` + +**Benefit:** 10-20% better compression ratio, 2× faster decompression + +### 8. String Views + +**Problem:** Unnecessary string copies when parsing + +**Solution:** Use `std::string_view` for zero-copy + +```cpp +// BAD: Copies substring +std::string extractCommand(const std::string &msg) { + return msg.substr(0, 5); // Copy +} + +// GOOD: View into original string +std::string_view extractCommand(std::string_view msg) { + return msg.substr(0, 5); // No copy +} +``` + +**Benefit:** Eliminates allocations during parsing + +## Compression (permessage-deflate) + +### WebSocket Compression Configuration + +```cpp +struct PerMessageDeflate { + z_stream deflate_stream; + z_stream inflate_stream; + + // Sliding window for compression history + static constexpr int WINDOW_BITS = 15; + static constexpr int MEM_LEVEL = 8; + + void init() { + // Initialize deflate (compression) + deflate_stream.zalloc = Z_NULL; + deflate_stream.zfree = Z_NULL; + deflate_stream.opaque = Z_NULL; + deflateInit2(&deflate_stream, + Z_DEFAULT_COMPRESSION, + Z_DEFLATED, + -WINDOW_BITS, // Negative = no zlib header + MEM_LEVEL, + Z_DEFAULT_STRATEGY); + + // Initialize inflate (decompression) + inflate_stream.zalloc = Z_NULL; + inflate_stream.zfree = Z_NULL; + inflate_stream.opaque = Z_NULL; + inflateInit2(&inflate_stream, -WINDOW_BITS); + } + + std::string compress(std::string_view data) { + // Compress with sliding window + deflate_stream.next_in = (Bytef*)data.data(); + deflate_stream.avail_in = data.size(); + + std::string compressed; + compressed.resize(deflateBound(&deflate_stream, data.size())); + + deflate_stream.next_out = (Bytef*)compressed.data(); + deflate_stream.avail_out = compressed.size(); + + deflate(&deflate_stream, Z_SYNC_FLUSH); + + compressed.resize(compressed.size() - deflate_stream.avail_out); + return compressed; + } +}; +``` + +**Typical compression ratios:** +- JSON events: 60-80% reduction +- Subscription filters: 40-60% reduction +- Binary events: 10-30% reduction + +## Database Schema (LMDB) + +strfry uses LMDB (Lightning Memory-Mapped Database) for event storage: + +```cpp +// Key-value stores +struct EventDB { + // Primary event storage (key: event ID, value: event data) + lmdb::dbi eventsDB; + + // Index by pubkey (key: pubkey + created_at, value: event ID) + lmdb::dbi pubkeyDB; + + // Index by kind (key: kind + created_at, value: event ID) + lmdb::dbi kindDB; + + // Index by tags (key: tag_name + tag_value + created_at, value: event ID) + lmdb::dbi tagsDB; + + // Deletion index (key: event ID, value: deletion event ID) + lmdb::dbi deletionsDB; +}; +``` + +**Why LMDB?** +- Memory-mapped I/O (kernel manages caching) +- Copy-on-write (MVCC without locks) +- Ordered keys (enables range queries) +- Crash-proof (no corruption on power loss) + +## Monitoring and Metrics + +### Connection Statistics + +```cpp +struct RelayStats { + std::atomic totalConnections{0}; + std::atomic activeConnections{0}; + std::atomic eventsReceived{0}; + std::atomic eventsSent{0}; + std::atomic bytesReceived{0}; + std::atomic bytesSent{0}; + + void recordConnection() { + totalConnections.fetch_add(1, std::memory_order_relaxed); + activeConnections.fetch_add(1, std::memory_order_relaxed); + } + + void recordDisconnection() { + activeConnections.fetch_sub(1, std::memory_order_relaxed); + } + + void recordEventReceived(size_t bytes) { + eventsReceived.fetch_add(1, std::memory_order_relaxed); + bytesReceived.fetch_add(bytes, std::memory_order_relaxed); + } +}; +``` + +**Atomic operations:** Lock-free updates from multiple threads + +### Performance Metrics + +```cpp +struct PerformanceMetrics { + // Latency histograms + Histogram eventIngestionLatency; + Histogram subscriptionQueryLatency; + Histogram eventBroadcastLatency; + + // Thread pool queue depths + std::atomic ingesterQueueDepth{0}; + std::atomic writerQueueDepth{0}; + std::atomic reqWorkerQueueDepth{0}; + + void recordIngestion(std::chrono::microseconds duration) { + eventIngestionLatency.record(duration.count()); + } +}; +``` + +## Configuration + +### relay.conf Example + +```ini +[relay] +bind = 0.0.0.0 +port = 8080 +maxConnections = 10000 +maxMessageSize = 16777216 # 16 MB + +[ingester] +threads = 3 +queueSize = 10000 + +[writer] +threads = 1 +queueSize = 1000 +batchSize = 100 + +[reqWorker] +threads = 3 +queueSize = 10000 + +[db] +path = /var/lib/strfry/events.lmdb +maxSizeGB = 100 +``` + +## Deployment Considerations + +### System Limits + +```bash +# Increase file descriptor limit +ulimit -n 65536 + +# Increase maximum socket connections +sysctl -w net.core.somaxconn=4096 + +# TCP tuning +sysctl -w net.ipv4.tcp_fin_timeout=15 +sysctl -w net.ipv4.tcp_tw_reuse=1 +``` + +### Memory Requirements + +**Per connection:** +- ConnectionState: ~1 KB +- WebSocket buffers: ~32 KB (16 KB send + 16 KB receive) +- Compression state: ~400 KB (200 KB deflate + 200 KB inflate) + +**Total:** ~433 KB per connection + +**For 10,000 connections:** ~4.3 GB + +### CPU Requirements + +**Single-core can handle:** +- 1000 concurrent connections +- 10,000 events/sec ingestion +- 100,000 events/sec broadcast (cached) + +**Recommended:** +- 8+ cores for 10,000 connections +- 16+ cores for 50,000 connections + +## Summary + +**Key architectural patterns:** +1. **Single-threaded I/O:** epoll handles all connections in one thread +2. **Specialized thread pools:** Different operations use dedicated threads +3. **Deterministic assignment:** Connection ID determines thread assignment +4. **Move semantics:** Zero-copy message passing +5. **Event batching:** Serialize once, send to many +6. **Pre-allocated buffers:** Reuse memory per connection +7. **Bloom filters:** Fast duplicate detection +8. **LMDB:** Memory-mapped database for zero-copy reads + +**Performance characteristics:** +- **50,000+ concurrent connections** per server +- **100,000+ events/sec** throughput +- **Sub-millisecond** latency for broadcasts +- **10 GB+ event database** with fast queries + +**When to use strfry patterns:** +- Need maximum performance (trading complexity) +- Have C++ expertise on team +- Running large public relay (thousands of users) +- Want minimal memory footprint +- Need to scale to 50K+ connections + +**Trade-offs:** +- **Complexity:** More complex than Go/Rust implementations +- **Portability:** Linux-specific (epoll, LMDB) +- **Development speed:** Slower iteration than higher-level languages + +**Further reading:** +- strfry repository: https://github.com/hoytech/strfry +- uWebSockets: https://github.com/uNetworking/uWebSockets +- LMDB: http://www.lmdb.tech/doc/ +- epoll: https://man7.org/linux/man-pages/man7/epoll.7.html diff --git a/.claude/skills/nostr-websocket/references/websocket_protocol.md b/.claude/skills/nostr-websocket/references/websocket_protocol.md new file mode 100644 index 00000000..dec88aa7 --- /dev/null +++ b/.claude/skills/nostr-websocket/references/websocket_protocol.md @@ -0,0 +1,881 @@ +# WebSocket Protocol (RFC 6455) - Complete Reference + +## Connection Establishment + +### HTTP Upgrade Handshake + +The WebSocket protocol begins as an HTTP request that upgrades to WebSocket: + +**Client Request:** +```http +GET /chat HTTP/1.1 +Host: server.example.com +Upgrade: websocket +Connection: Upgrade +Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== +Origin: http://example.com +Sec-WebSocket-Protocol: chat, superchat +Sec-WebSocket-Version: 13 +``` + +**Server Response:** +```http +HTTP/1.1 101 Switching Protocols +Upgrade: websocket +Connection: Upgrade +Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= +Sec-WebSocket-Protocol: chat +``` + +### Handshake Details + +**Sec-WebSocket-Key Generation (Client):** +1. Generate 16 random bytes +2. Base64-encode the result +3. Send in `Sec-WebSocket-Key` header + +**Sec-WebSocket-Accept Computation (Server):** +1. Concatenate client key with GUID: `258EAFA5-E914-47DA-95CA-C5AB0DC85B11` +2. Compute SHA-1 hash of concatenated string +3. Base64-encode the hash +4. Send in `Sec-WebSocket-Accept` header + +**Example computation:** +``` +Client Key: dGhlIHNhbXBsZSBub25jZQ== +Concatenated: dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11 +SHA-1 Hash: b37a4f2cc0cb4e7e8cf769a5f3f8f2e8e4c9f7a3 +Base64: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= +``` + +**Validation (Client):** +- Verify HTTP status is 101 +- Verify `Sec-WebSocket-Accept` matches expected value +- If validation fails, do not establish connection + +### Origin Header + +The `Origin` header provides protection against cross-site WebSocket hijacking: + +**Server-side validation:** +```go +func checkOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + allowedOrigins := []string{ + "https://example.com", + "https://app.example.com", + } + for _, allowed := range allowedOrigins { + if origin == allowed { + return true + } + } + return false +} +``` + +**Security consideration:** Browser-based clients MUST send Origin header. Non-browser clients MAY omit it. Servers SHOULD validate Origin for browser clients to prevent CSRF attacks. + +## Frame Format + +### Base Framing Protocol + +WebSocket frames use a binary format with variable-length fields: + +``` + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-------+-+-------------+-------------------------------+ + |F|R|R|R| opcode|M| Payload len | Extended payload length | + |I|S|S|S| (4) |A| (7) | (16/64) | + |N|V|V|V| |S| | (if payload len==126/127) | + | |1|2|3| |K| | | + +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + + | Extended payload length continued, if payload len == 127 | + + - - - - - - - - - - - - - - - +-------------------------------+ + | |Masking-key, if MASK set to 1 | + +-------------------------------+-------------------------------+ + | Masking-key (continued) | Payload Data | + +-------------------------------- - - - - - - - - - - - - - - - + + : Payload Data continued ... : + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + | Payload Data continued ... | + +---------------------------------------------------------------+ +``` + +### Frame Header Fields + +**FIN (1 bit):** +- `1` = Final fragment in message +- `0` = More fragments follow +- Used for message fragmentation + +**RSV1, RSV2, RSV3 (1 bit each):** +- Reserved for extensions +- MUST be 0 unless extension negotiated +- Server MUST fail connection if non-zero with no extension + +**Opcode (4 bits):** +- Defines interpretation of payload data +- See "Frame Opcodes" section below + +**MASK (1 bit):** +- `1` = Payload is masked (required for client-to-server) +- `0` = Payload is not masked (required for server-to-client) +- Client MUST mask all frames sent to server +- Server MUST NOT mask frames sent to client + +**Payload Length (7 bits, 7+16 bits, or 7+64 bits):** +- If 0-125: Actual payload length +- If 126: Next 2 bytes are 16-bit unsigned payload length +- If 127: Next 8 bytes are 64-bit unsigned payload length + +**Masking-key (0 or 4 bytes):** +- Present if MASK bit is set +- 32-bit value used to mask payload +- MUST be unpredictable (strong entropy source) + +### Frame Opcodes + +**Data Frame Opcodes:** +- `0x0` - Continuation Frame + - Used for fragmented messages + - Must follow initial data frame (text/binary) + - Carries same data type as initial frame + +- `0x1` - Text Frame + - Payload is UTF-8 encoded text + - MUST be valid UTF-8 + - Endpoint MUST fail connection if invalid UTF-8 + +- `0x2` - Binary Frame + - Payload is arbitrary binary data + - Application interprets data + +- `0x3-0x7` - Reserved for future non-control frames + +**Control Frame Opcodes:** +- `0x8` - Connection Close + - Initiates or acknowledges connection closure + - MAY contain status code and reason + - See "Close Handshake" section + +- `0x9` - Ping + - Heartbeat mechanism + - MAY contain application data + - Recipient MUST respond with Pong + +- `0xA` - Pong + - Response to Ping + - MUST contain identical payload as Ping + - MAY be sent unsolicited (unidirectional heartbeat) + +- `0xB-0xF` - Reserved for future control frames + +### Control Frame Constraints + +**Control frames are subject to strict rules:** + +1. **Maximum payload:** 125 bytes + - Allows control frames to fit in single IP packet + - Reduces fragmentation + +2. **No fragmentation:** Control frames MUST NOT be fragmented + - FIN bit MUST be 1 + - Ensures immediate processing + +3. **Interleaving:** Control frames MAY be injected in middle of fragmented message + - Enables ping/pong during long transfers + - Close frames can interrupt any operation + +4. **All control frames MUST be handled immediately** + +### Masking + +**Purpose of masking:** +- Prevents cache poisoning attacks +- Protects against misinterpretation by intermediaries +- Makes WebSocket traffic unpredictable to proxies + +**Masking algorithm:** +``` +j = i MOD 4 +transformed-octet-i = original-octet-i XOR masking-key-octet-j +``` + +**Implementation:** +```go +func maskBytes(data []byte, mask [4]byte) { + for i := range data { + data[i] ^= mask[i%4] + } +} +``` + +**Example:** +``` +Original: [0x48, 0x65, 0x6C, 0x6C, 0x6F] // "Hello" +Masking Key: [0x37, 0xFA, 0x21, 0x3D] +Masked: [0x7F, 0x9F, 0x4D, 0x51, 0x58] + +Calculation: +0x48 XOR 0x37 = 0x7F +0x65 XOR 0xFA = 0x9F +0x6C XOR 0x21 = 0x4D +0x6C XOR 0x3D = 0x51 +0x6F XOR 0x37 = 0x58 (wraps around to mask[0]) +``` + +**Security requirement:** Masking key MUST be derived from strong source of entropy. Predictable masking keys defeat the security purpose. + +## Message Fragmentation + +### Why Fragment? + +- Send message without knowing total size upfront +- Multiplex logical channels (interleave messages) +- Keep control frames responsive during large transfers + +### Fragmentation Rules + +**Sender rules:** +1. First fragment has opcode (text/binary) +2. Subsequent fragments have opcode 0x0 (continuation) +3. Last fragment has FIN bit set to 1 +4. Control frames MAY be interleaved + +**Receiver rules:** +1. Reassemble fragments in order +2. Final message type determined by first fragment opcode +3. Validate UTF-8 across all text fragments +4. Process control frames immediately (don't wait for FIN) + +### Fragmentation Example + +**Sending "Hello World" in 3 fragments:** + +``` +Frame 1 (Text, More Fragments): + FIN=0, Opcode=0x1, Payload="Hello" + +Frame 2 (Continuation, More Fragments): + FIN=0, Opcode=0x0, Payload=" Wor" + +Frame 3 (Continuation, Final): + FIN=1, Opcode=0x0, Payload="ld" +``` + +**With interleaved Ping:** + +``` +Frame 1: FIN=0, Opcode=0x1, Payload="Hello" +Frame 2: FIN=1, Opcode=0x9, Payload="" <- Ping (complete) +Frame 3: FIN=0, Opcode=0x0, Payload=" Wor" +Frame 4: FIN=1, Opcode=0x0, Payload="ld" +``` + +### Implementation Pattern + +```go +type fragmentState struct { + messageType int + fragments [][]byte +} + +func (ws *WebSocket) handleFrame(fin bool, opcode int, payload []byte) { + switch opcode { + case 0x1, 0x2: // Text or Binary (first fragment) + if fin { + ws.handleCompleteMessage(opcode, payload) + } else { + ws.fragmentState = &fragmentState{ + messageType: opcode, + fragments: [][]byte{payload}, + } + } + + case 0x0: // Continuation + if ws.fragmentState == nil { + ws.fail("Unexpected continuation frame") + return + } + ws.fragmentState.fragments = append(ws.fragmentState.fragments, payload) + if fin { + complete := bytes.Join(ws.fragmentState.fragments, nil) + ws.handleCompleteMessage(ws.fragmentState.messageType, complete) + ws.fragmentState = nil + } + + case 0x8, 0x9, 0xA: // Control frames + ws.handleControlFrame(opcode, payload) + } +} +``` + +## Ping and Pong Frames + +### Purpose + +1. **Keep-alive:** Detect broken connections +2. **Latency measurement:** Time round-trip +3. **NAT traversal:** Maintain mapping in stateful firewalls + +### Protocol Rules + +**Ping (0x9):** +- MAY be sent by either endpoint at any time +- MAY contain application data (≤125 bytes) +- Application data arbitrary (often empty or timestamp) + +**Pong (0xA):** +- MUST be sent in response to Ping +- MUST contain identical payload as Ping +- MUST be sent "as soon as practical" +- MAY be sent unsolicited (one-way heartbeat) + +**No Response:** +- If Pong not received within timeout, connection assumed dead +- Application should close connection + +### Implementation Patterns + +**Pattern 1: Automatic Pong (most WebSocket libraries)** +```go +// Library handles pong automatically +ws.SetPingHandler(func(appData string) error { + // Custom handler if needed + return nil // Library sends pong automatically +}) +``` + +**Pattern 2: Manual Pong** +```go +func (ws *WebSocket) handlePing(payload []byte) { + pongFrame := Frame{ + FIN: true, + Opcode: 0xA, + Payload: payload, // Echo same payload + } + ws.writeFrame(pongFrame) +} +``` + +**Pattern 3: Periodic Client Ping** +```go +func (ws *WebSocket) pingLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := ws.writePing([]byte{}); err != nil { + return // Connection dead + } + case <-ws.done: + return + } + } +} +``` + +**Pattern 4: Timeout Detection** +```go +const pongWait = 60 * time.Second + +ws.SetReadDeadline(time.Now().Add(pongWait)) +ws.SetPongHandler(func(string) error { + ws.SetReadDeadline(time.Now().Add(pongWait)) + return nil +}) + +// If no frame received in pongWait, ReadMessage returns timeout error +``` + +### Nostr Relay Recommendations + +**Server-side:** +- Send ping every 30-60 seconds +- Close connection if no pong within 60-120 seconds +- Log timeout closures for monitoring + +**Client-side:** +- Respond to pings automatically (use library handler) +- Consider sending unsolicited pongs every 30 seconds (some proxies) +- Reconnect if no frames received for 120 seconds + +## Close Handshake + +### Close Frame Structure + +**Close frame (Opcode 0x8) payload:** +``` + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Status Code (16) | Reason (variable length)... | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +**Status Code (2 bytes, optional):** +- 16-bit unsigned integer +- Network byte order (big-endian) +- See "Status Codes" section below + +**Reason (variable length, optional):** +- UTF-8 encoded text +- MUST be valid UTF-8 +- Typically human-readable explanation + +### Close Handshake Sequence + +**Initiator (either endpoint):** +1. Send Close frame with optional status/reason +2. Stop sending data frames +3. Continue processing received frames until Close frame received +4. Close underlying TCP connection + +**Recipient:** +1. Receive Close frame +2. Send Close frame in response (if not already sent) +3. Close underlying TCP connection + +### Status Codes + +**Normal Closure Codes:** +- `1000` - Normal Closure + - Successful operation complete + - Default if no code specified + +- `1001` - Going Away + - Endpoint going away (server shutdown, browser navigation) + - Client navigating to new page + +**Error Closure Codes:** +- `1002` - Protocol Error + - Endpoint terminating due to protocol error + - Invalid frame format, unexpected opcode, etc. + +- `1003` - Unsupported Data + - Endpoint cannot accept data type + - Server received binary when expecting text + +- `1007` - Invalid Frame Payload Data + - Inconsistent data (e.g., non-UTF-8 in text frame) + +- `1008` - Policy Violation + - Message violates endpoint policy + - Generic code when specific code doesn't fit + +- `1009` - Message Too Big + - Message too large to process + +- `1010` - Mandatory Extension + - Client expected server to negotiate extension + - Server didn't respond with extension + +- `1011` - Internal Server Error + - Server encountered unexpected condition + - Prevents fulfilling request + +**Reserved Codes:** +- `1004` - Reserved +- `1005` - No Status Rcvd (internal use only, never sent) +- `1006` - Abnormal Closure (internal use only, never sent) +- `1015` - TLS Handshake (internal use only, never sent) + +**Custom Application Codes:** +- `3000-3999` - Library/framework use +- `4000-4999` - Application use (e.g., Nostr-specific) + +### Implementation Patterns + +**Graceful close (initiator):** +```go +func (ws *WebSocket) Close() error { + // Send close frame + closeFrame := Frame{ + FIN: true, + Opcode: 0x8, + Payload: encodeCloseStatus(1000, "goodbye"), + } + ws.writeFrame(closeFrame) + + // Wait for close frame response (with timeout) + ws.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + frame, err := ws.readFrame() + if err != nil || frame.Opcode == 0x8 { + break + } + // Process other frames + } + + // Close TCP connection + return ws.conn.Close() +} +``` + +**Handling received close:** +```go +func (ws *WebSocket) handleCloseFrame(payload []byte) { + status, reason := decodeClosePayload(payload) + log.Printf("Close received: %d %s", status, reason) + + // Send close response + closeFrame := Frame{ + FIN: true, + Opcode: 0x8, + Payload: payload, // Echo same status/reason + } + ws.writeFrame(closeFrame) + + // Close connection + ws.conn.Close() +} +``` + +**Nostr relay close examples:** +```go +// Client subscription limit exceeded +ws.SendClose(4000, "subscription limit exceeded") + +// Invalid message format +ws.SendClose(1002, "protocol error: invalid JSON") + +// Relay shutting down +ws.SendClose(1001, "relay shutting down") + +// Client rate limit exceeded +ws.SendClose(4001, "rate limit exceeded") +``` + +## Security Considerations + +### Origin-Based Security Model + +**Threat:** Malicious web page opens WebSocket to victim server using user's credentials + +**Mitigation:** +1. Server checks `Origin` header +2. Reject connections from untrusted origins +3. Implement same-origin or allowlist policy + +**Example:** +```go +func validateOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + + // Allow same-origin + if origin == "https://"+r.Host { + return true + } + + // Allowlist trusted origins + trusted := []string{ + "https://app.example.com", + "https://mobile.example.com", + } + for _, t := range trusted { + if origin == t { + return true + } + } + + return false +} +``` + +### Masking Attacks + +**Why masking is required:** +- Without masking, attacker can craft WebSocket frames that look like HTTP requests +- Proxies might misinterpret frame data as HTTP +- Could lead to cache poisoning or request smuggling + +**Example attack (without masking):** +``` +WebSocket payload: "GET /admin HTTP/1.1\r\nHost: victim.com\r\n\r\n" +Proxy might interpret as separate HTTP request +``` + +**Defense:** Client MUST mask all frames. Server MUST reject unmasked frames from client. + +### Connection Limits + +**Prevent resource exhaustion:** + +```go +type ConnectionLimiter struct { + connections map[string]int + maxPerIP int + mu sync.Mutex +} + +func (cl *ConnectionLimiter) Allow(ip string) bool { + cl.mu.Lock() + defer cl.mu.Unlock() + + if cl.connections[ip] >= cl.maxPerIP { + return false + } + cl.connections[ip]++ + return true +} + +func (cl *ConnectionLimiter) Release(ip string) { + cl.mu.Lock() + defer cl.mu.Unlock() + cl.connections[ip]-- +} +``` + +### TLS (WSS) + +**Use WSS (WebSocket Secure) for:** +- Authentication credentials +- Private user data +- Financial transactions +- Any sensitive information + +**WSS connection flow:** +1. Establish TLS connection +2. Perform TLS handshake +3. Verify server certificate +4. Perform WebSocket handshake over TLS + +**URL schemes:** +- `ws://` - Unencrypted WebSocket (default port 80) +- `wss://` - Encrypted WebSocket over TLS (default port 443) + +### Message Size Limits + +**Prevent memory exhaustion:** + +```go +const maxMessageSize = 512 * 1024 // 512 KB + +ws.SetReadLimit(maxMessageSize) + +// Or during frame reading: +if payloadLength > maxMessageSize { + ws.SendClose(1009, "message too large") + ws.Close() +} +``` + +### Rate Limiting + +**Prevent abuse:** + +```go +type RateLimiter struct { + limiter *rate.Limiter +} + +func (rl *RateLimiter) Allow() bool { + return rl.limiter.Allow() +} + +// Per-connection limiter +limiter := rate.NewLimiter(10, 20) // 10 msgs/sec, burst 20 + +if !limiter.Allow() { + ws.SendClose(4001, "rate limit exceeded") +} +``` + +## Error Handling + +### Connection Errors + +**Types of errors:** +1. **Network errors:** TCP connection failure, timeout +2. **Protocol errors:** Invalid frame format, wrong opcode +3. **Application errors:** Invalid message content + +**Handling strategy:** +```go +for { + frame, err := ws.ReadFrame() + if err != nil { + // Check error type + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + // Timeout - connection likely dead + log.Println("Connection timeout") + ws.Close() + return + } + + if err == io.EOF || err == io.ErrUnexpectedEOF { + // Connection closed + log.Println("Connection closed") + return + } + + if protocolErr, ok := err.(*ProtocolError); ok { + // Protocol violation + log.Printf("Protocol error: %v", protocolErr) + ws.SendClose(1002, protocolErr.Error()) + ws.Close() + return + } + + // Unknown error + log.Printf("Unknown error: %v", err) + ws.Close() + return + } + + // Process frame +} +``` + +### UTF-8 Validation + +**Text frames MUST contain valid UTF-8:** + +```go +func validateUTF8(data []byte) bool { + return utf8.Valid(data) +} + +func handleTextFrame(payload []byte) error { + if !validateUTF8(payload) { + return fmt.Errorf("invalid UTF-8 in text frame") + } + // Process valid text + return nil +} +``` + +**For fragmented messages:** Validate UTF-8 across all fragments when reassembled. + +## Implementation Checklist + +### Client Implementation + +- [ ] Generate random Sec-WebSocket-Key +- [ ] Compute and validate Sec-WebSocket-Accept +- [ ] MUST mask all frames sent to server +- [ ] Handle unmasked frames from server +- [ ] Respond to Ping with Pong +- [ ] Implement close handshake (both initiating and responding) +- [ ] Validate UTF-8 in text frames +- [ ] Handle fragmented messages +- [ ] Set reasonable timeouts +- [ ] Implement reconnection logic + +### Server Implementation + +- [ ] Validate Sec-WebSocket-Key format +- [ ] Compute correct Sec-WebSocket-Accept +- [ ] Validate Origin header +- [ ] MUST NOT mask frames sent to client +- [ ] Reject masked frames from server (protocol error) +- [ ] Respond to Ping with Pong +- [ ] Implement close handshake (both initiating and responding) +- [ ] Validate UTF-8 in text frames +- [ ] Handle fragmented messages +- [ ] Implement connection limits (per IP, total) +- [ ] Implement message size limits +- [ ] Implement rate limiting +- [ ] Log connection statistics +- [ ] Graceful shutdown (close all connections) + +### Both Client and Server + +- [ ] Handle concurrent read/write safely +- [ ] Process control frames immediately (even during fragmentation) +- [ ] Implement proper timeout mechanisms +- [ ] Log errors with appropriate detail +- [ ] Handle unexpected close gracefully +- [ ] Validate frame structure +- [ ] Check RSV bits (must be 0 unless extension) +- [ ] Support standard close status codes +- [ ] Implement proper error handling for all operations + +## Common Implementation Mistakes + +### 1. Concurrent Writes + +**Mistake:** Writing to WebSocket from multiple goroutines without synchronization + +**Fix:** Use mutex or single-writer goroutine +```go +type WebSocket struct { + conn *websocket.Conn + mutex sync.Mutex +} + +func (ws *WebSocket) WriteMessage(data []byte) error { + ws.mutex.Lock() + defer ws.mutex.Unlock() + return ws.conn.WriteMessage(websocket.TextMessage, data) +} +``` + +### 2. Not Handling Pong + +**Mistake:** Sending Ping but not updating read deadline on Pong + +**Fix:** +```go +ws.SetPongHandler(func(string) error { + ws.SetReadDeadline(time.Now().Add(pongWait)) + return nil +}) +``` + +### 3. Forgetting Close Handshake + +**Mistake:** Just calling `conn.Close()` without sending Close frame + +**Fix:** Send Close frame first, wait for response, then close TCP + +### 4. Not Validating UTF-8 + +**Mistake:** Accepting any bytes in text frames + +**Fix:** Validate UTF-8 and fail connection on invalid text + +### 5. No Message Size Limit + +**Mistake:** Allowing unlimited message sizes + +**Fix:** Set `SetReadLimit()` to reasonable value (e.g., 512 KB) + +### 6. Blocking on Write + +**Mistake:** Blocking indefinitely on slow clients + +**Fix:** Set write deadline before each write +```go +ws.SetWriteDeadline(time.Now().Add(10 * time.Second)) +``` + +### 7. Memory Leaks + +**Mistake:** Not cleaning up resources on disconnect + +**Fix:** Use defer for cleanup, ensure all goroutines terminate + +### 8. Race Conditions in Close + +**Mistake:** Multiple goroutines trying to close connection + +**Fix:** Use `sync.Once` for close operation +```go +type WebSocket struct { + conn *websocket.Conn + closeOnce sync.Once +} + +func (ws *WebSocket) Close() error { + var err error + ws.closeOnce.Do(func() { + err = ws.conn.Close() + }) + return err +} +``` diff --git a/.claude/skills/nostr/README.md b/.claude/skills/nostr/README.md new file mode 100644 index 00000000..6806b778 --- /dev/null +++ b/.claude/skills/nostr/README.md @@ -0,0 +1,162 @@ +# Nostr Protocol Skill + +A comprehensive Claude skill for working with the Nostr protocol and implementing Nostr clients and relays. + +## Overview + +This skill provides expert-level knowledge of the Nostr protocol, including: +- Complete NIP (Nostr Implementation Possibilities) reference +- Event structure and cryptographic operations +- Client-relay WebSocket communication +- Event kinds and their behaviors +- Best practices and common pitfalls + +## Contents + +### SKILL.md +The main skill file containing: +- Core protocol concepts +- Event structure and signing +- WebSocket communication patterns +- Cryptographic operations +- Common implementation patterns +- Quick reference guides + +### Reference Files + +#### references/nips-overview.md +Comprehensive documentation of all standard NIPs including: +- Core protocol NIPs (NIP-01, NIP-02, etc.) +- Social features (reactions, reposts, channels) +- Identity and discovery (NIP-05, NIP-65) +- Security and privacy (NIP-44, NIP-42) +- Lightning integration (NIP-47, NIP-57) +- Advanced features + +#### references/event-kinds.md +Complete reference for all Nostr event kinds: +- Core events (0-999) +- Regular events (1000-9999) +- Replaceable events (10000-19999) +- Ephemeral events (20000-29999) +- Parameterized replaceable events (30000-39999) +- Event lifecycle behaviors +- Common patterns and examples + +#### references/common-mistakes.md +Detailed guide on implementation pitfalls: +- Event creation and signing errors +- WebSocket communication issues +- Filter query problems +- Threading mistakes +- Relay management errors +- Security vulnerabilities +- UX considerations +- Testing strategies + +## When to Use + +Use this skill when: +- Implementing Nostr clients or relays +- Working with Nostr events and messages +- Handling cryptographic signatures and keys +- Implementing any NIP +- Building social features on Nostr +- Debugging Nostr applications +- Discussing Nostr protocol architecture + +## Key Features + +### Complete NIP Coverage +All standard NIPs documented with: +- Purpose and status +- Implementation details +- Code examples +- Usage patterns +- Interoperability notes + +### Cryptographic Operations +Detailed guidance on: +- Event signing with Schnorr signatures +- Event ID calculation +- Signature verification +- Key management (BIP-39, NIP-06) +- Encryption (NIP-04, NIP-44) + +### WebSocket Protocol +Complete reference for: +- Message types (EVENT, REQ, CLOSE, OK, EOSE, etc.) +- Filter queries and optimization +- Subscription management +- Connection handling +- Error handling + +### Event Lifecycle +Understanding of: +- Regular events (immutable) +- Replaceable events (latest only) +- Ephemeral events (real-time only) +- Parameterized replaceable events (by identifier) + +### Best Practices +Comprehensive guidance on: +- Multi-relay architecture +- NIP-65 relay lists +- Event caching +- Optimistic UI +- Security considerations +- Performance optimization + +## Quick Start Examples + +### Publishing a Note +```javascript +const event = { + pubkey: userPublicKey, + created_at: Math.floor(Date.now() / 1000), + kind: 1, + tags: [], + content: "Hello Nostr!" +} +event.id = calculateId(event) +event.sig = signEvent(event, privateKey) +ws.send(JSON.stringify(["EVENT", event])) +``` + +### Subscribing to Events +```javascript +const filter = { + kinds: [1], + authors: [followedPubkey], + limit: 50 +} +ws.send(JSON.stringify(["REQ", "sub-id", filter])) +``` + +### Replying to a Note +```javascript +const reply = { + kind: 1, + tags: [ + ["e", originalEventId, "", "root"], + ["p", originalAuthorPubkey] + ], + content: "Great post!" +} +``` + +## Official Resources + +- **NIPs Repository**: https://github.com/nostr-protocol/nips +- **Nostr Website**: https://nostr.com +- **Nostr Documentation**: https://nostr.how +- **NIP Status**: https://nostr-nips.com + +## Skill Maintenance + +This skill is based on the official Nostr NIPs repository. As new NIPs are proposed and implemented, this skill should be updated to reflect the latest standards and best practices. + +## License + +Based on public Nostr protocol specifications (MIT License). + diff --git a/.claude/skills/nostr/SKILL.md b/.claude/skills/nostr/SKILL.md new file mode 100644 index 00000000..357d51aa --- /dev/null +++ b/.claude/skills/nostr/SKILL.md @@ -0,0 +1,459 @@ +--- +name: nostr +description: This skill should be used when working with the Nostr protocol, implementing Nostr clients or relays, handling Nostr events, or discussing Nostr Implementation Possibilities (NIPs). Provides comprehensive knowledge of Nostr's decentralized protocol, event structure, cryptographic operations, and all standard NIPs. +--- + +# Nostr Protocol Expert + +## Purpose + +This skill provides expert-level assistance with the Nostr protocol, a simple, open protocol for global, decentralized, and censorship-resistant social networks. The protocol is built on relays and cryptographic keys, enabling direct peer-to-peer communication without central servers. + +## When to Use + +Activate this skill when: +- Implementing Nostr clients or relays +- Working with Nostr events and messages +- Handling cryptographic signatures and keys (schnorr signatures on secp256k1) +- Implementing any Nostr Implementation Possibility (NIP) +- Building social networking features on Nostr +- Querying or filtering Nostr events +- Discussing Nostr protocol architecture +- Implementing WebSocket communication with relays + +## Core Concepts + +### The Protocol Foundation + +Nostr operates on two main components: +1. **Clients** - Applications users run to read/write data +2. **Relays** - Servers that store and forward messages + +Key principles: +- Everyone runs a client +- Anyone can run a relay +- Users identified by public keys +- Messages signed with private keys +- No central authority or trusted servers + +### Events Structure + +All data in Nostr is represented as events. An event is a JSON object with this structure: + +```json +{ + "id": "<32-bytes lowercase hex-encoded sha256 of the serialized event data>", + "pubkey": "<32-bytes lowercase hex-encoded public key of the event creator>", + "created_at": "", + "kind": "", + "tags": [ + ["", "", "", "..."] + ], + "content": "", + "sig": "<64-bytes lowercase hex of the schnorr signature of the sha256 hash of the serialized event data>" +} +``` + +### Event Kinds + +Standard event kinds (from various NIPs): +- `0` - Metadata (user profile) +- `1` - Text note (short post) +- `2` - Recommend relay +- `3` - Contacts (following list) +- `4` - Encrypted direct messages +- `5` - Event deletion +- `6` - Repost +- `7` - Reaction (like, emoji reaction) +- `40` - Channel creation +- `41` - Channel metadata +- `42` - Channel message +- `43` - Channel hide message +- `44` - Channel mute user +- `1000-9999` - Regular events +- `10000-19999` - Replaceable events +- `20000-29999` - Ephemeral events +- `30000-39999` - Parameterized replaceable events + +### Tags + +Common tag types: +- `["e", "", "", ""]` - Reference to an event +- `["p", "", ""]` - Reference to a user +- `["a", "::", ""]` - Reference to a replaceable event +- `["d", ""]` - Identifier for parameterized replaceable events +- `["r", ""]` - Reference/link to a web resource +- `["t", ""]` - Hashtag +- `["g", ""]` - Geolocation +- `["nonce", "", ""]` - Proof of work +- `["subject", ""]` - Subject/title +- `["client", ""]` - Client application used + +## Key NIPs Reference + +For detailed specifications, refer to **references/nips-overview.md**. + +### Core Protocol NIPs + +#### NIP-01: Basic Protocol Flow +The foundation of Nostr. Defines: +- Event structure and validation +- Event ID calculation (SHA256 of serialized event) +- Signature verification (schnorr signatures) +- Client-relay communication via WebSocket +- Message types: EVENT, REQ, CLOSE, EOSE, OK, NOTICE + +#### NIP-02: Contact List and Petnames +Event kind `3` for following lists: +- Each `p` tag represents a followed user +- Optional relay URL and petname in tag +- Replaceable event (latest overwrites) + +#### NIP-04: Encrypted Direct Messages +Event kind `4` for private messages: +- Content encrypted with shared secret (ECDH) +- `p` tag for recipient pubkey +- Deprecated in favor of NIP-44 + +#### NIP-05: Mapping Nostr Keys to DNS +Internet identifier format: `name@domain.com` +- `.well-known/nostr.json` endpoint +- Maps names to pubkeys +- Optional relay list + +#### NIP-09: Event Deletion +Event kind `5` to request deletion: +- Contains `e` tags for events to delete +- Relays should delete referenced events +- Only works for own events + +#### NIP-10: Text Note References (Threads) +Conventions for `e` and `p` tags in replies: +- Root event reference +- Reply event reference +- Mentions +- Marker types: "root", "reply", "mention" + +#### NIP-11: Relay Information Document +HTTP endpoint for relay metadata: +- GET request to relay URL +- Returns JSON with relay information +- Supported NIPs, software, limitations + +### Social Features NIPs + +#### NIP-25: Reactions +Event kind `7` for reactions: +- Content usually "+" (like) or emoji +- `e` tag for reacted event +- `p` tag for event author + +#### NIP-42: Authentication +Client authentication to relays: +- AUTH message from relay (challenge) +- Client responds with event kind `22242` signed auth event +- Proves key ownership + +**CRITICAL: Clients MUST wait for OK response after AUTH** +- Relays MUST respond to AUTH with an OK message (same as EVENT) +- An OK with `true` confirms the relay has stored the authenticated pubkey +- An OK with `false` indicates authentication failed: + 1. **Alert the user** that authentication failed + 2. **Assume the relay will reject** subsequent events requiring auth + 3. Check the `reason` field for error details (e.g., "error: failed to parse auth event") +- Do NOT send events requiring authentication until OK `true` is received +- If no OK is received within timeout, assume connection issues and retry or alert user + +#### NIP-50: Search +Query filter extension for full-text search: +- `search` field in REQ filters +- Implementation-defined behavior + +### Advanced NIPs + +#### NIP-19: bech32-encoded Entities +Human-readable identifiers: +- `npub`: public key +- `nsec`: private key (sensitive!) +- `note`: note/event ID +- `nprofile`: profile with relay hints +- `nevent`: event with relay hints +- `naddr`: replaceable event coordinate + +#### NIP-44: Encrypted Payloads +Improved encryption for direct messages: +- Versioned encryption scheme +- Better security than NIP-04 +- ChaCha20-Poly1305 AEAD + +#### NIP-65: Relay List Metadata +Event kind `10002` for relay lists: +- Read/write relay preferences +- Optimizes relay discovery +- Replaceable event + +## Client-Relay Communication + +### WebSocket Messages + +#### From Client to Relay + +**EVENT** - Publish an event: +```json +["EVENT", ] +``` + +**REQ** - Request events (subscription): +```json +["REQ", , , , ...] +``` + +**CLOSE** - Stop a subscription: +```json +["CLOSE", ] +``` + +**AUTH** - Respond to auth challenge: +```json +["AUTH", ] +``` + +#### From Relay to Client + +**EVENT** - Send event to client: +```json +["EVENT", , ] +``` + +**OK** - Acceptance/rejection notice: +```json +["OK", , , ] +``` + +**EOSE** - End of stored events: +```json +["EOSE", ] +``` + +**CLOSED** - Subscription closed: +```json +["CLOSED", , ] +``` + +**NOTICE** - Human-readable message: +```json +["NOTICE", ] +``` + +**AUTH** - Authentication challenge: +```json +["AUTH", ] +``` + +### Filter Objects + +Filters select events in REQ messages: + +```json +{ + "ids": ["", ...], + "authors": ["", ...], + "kinds": [, ...], + "#e": ["", ...], + "#p": ["", ...], + "#a": ["", ...], + "#t": ["", ...], + "since": , + "until": , + "limit": +} +``` + +Filtering rules: +- Arrays are ORed together +- Different fields are ANDed +- Tag filters: `#` matches tag values +- Prefix matching allowed for `ids` and `authors` + +## Cryptographic Operations + +### Key Management + +- **Private Key**: 32-byte random value, keep secure +- **Public Key**: Derived via secp256k1 +- **Encoding**: Hex (lowercase) or bech32 + +### Event Signing (schnorr) + +Steps to create a signed event: +1. Set all fields except `id` and `sig` +2. Serialize event data to JSON (specific order) +3. Calculate SHA256 hash → `id` +4. Sign `id` with schnorr signature → `sig` + +Serialization format for ID calculation: +```json +[ + 0, + , + , + , + , + +] +``` + +### Event Verification + +Steps to verify an event: +1. Verify ID matches SHA256 of serialized data +2. Verify signature is valid schnorr signature +3. Check created_at is reasonable (not far future) +4. Validate event structure and required fields + +## Implementation Best Practices + +### For Clients + +1. **Connect to Multiple Relays**: Don't rely on single relay +2. **Cache Events**: Reduce redundant relay queries +3. **Verify Signatures**: Always verify event signatures +4. **Handle Replaceable Events**: Keep only latest version +5. **Respect User Privacy**: Careful with sensitive data +6. **Implement NIP-65**: Use user's preferred relays +7. **Proper Error Handling**: Handle relay disconnections +8. **Pagination**: Use `limit`, `since`, `until` for queries + +### For Relays + +1. **Validate Events**: Check signatures, IDs, structure +2. **Rate Limiting**: Prevent spam and abuse +3. **Storage Management**: Ephemeral events, retention policies +4. **Implement NIP-11**: Provide relay information +5. **WebSocket Optimization**: Handle many connections +6. **Filter Optimization**: Efficient event querying +7. **Consider NIP-42**: Authentication for write access +8. **Performance**: Index by pubkey, kind, tags, timestamp + +### Security Considerations + +1. **Never Expose Private Keys**: Handle nsec carefully +2. **Validate All Input**: Prevent injection attacks +3. **Use NIP-44**: For encrypted messages (not NIP-04) +4. **Check Event Timestamps**: Reject far-future events +5. **Implement Proof of Work**: NIP-13 for spam prevention +6. **Sanitize Content**: XSS prevention in displayed content +7. **Relay Trust**: Don't trust single relay for critical data + +## Common Patterns + +### Publishing a Note + +```javascript +const event = { + pubkey: userPublicKey, + created_at: Math.floor(Date.now() / 1000), + kind: 1, + tags: [], + content: "Hello Nostr!", +} +// Calculate ID and sign +event.id = calculateId(event) +event.sig = signEvent(event, privateKey) +// Publish to relay +ws.send(JSON.stringify(["EVENT", event])) +``` + +### Subscribing to Notes + +```javascript +const filter = { + kinds: [1], + authors: [followedPubkey1, followedPubkey2], + limit: 50 +} +ws.send(JSON.stringify(["REQ", "my-sub", filter])) +``` + +### Replying to a Note + +```javascript +const reply = { + kind: 1, + tags: [ + ["e", originalEventId, relayUrl, "root"], + ["p", originalAuthorPubkey] + ], + content: "Great post!", + // ... other fields +} +``` + +### Reacting to a Note + +```javascript +const reaction = { + kind: 7, + tags: [ + ["e", eventId], + ["p", eventAuthorPubkey] + ], + content: "+", // or emoji + // ... other fields +} +``` + +## Development Resources + +### Essential NIPs for Beginners + +Start with these NIPs in order: +1. **NIP-01** - Basic protocol (MUST read) +2. **NIP-19** - Bech32 identifiers +3. **NIP-02** - Following lists +4. **NIP-10** - Threaded conversations +5. **NIP-25** - Reactions +6. **NIP-65** - Relay lists + +### Testing and Development + +- **Relay Implementations**: nostream, strfry, relay.py +- **Test Relays**: wss://relay.damus.io, wss://nos.lol +- **Libraries**: nostr-tools (JS), rust-nostr (Rust), python-nostr (Python) +- **Development Tools**: NostrDebug, Nostr Army Knife, nostril +- **Reference Clients**: Damus (iOS), Amethyst (Android), Snort (Web) + +### Key Repositories + +- **NIPs Repository**: https://github.com/nostr-protocol/nips +- **Awesome Nostr**: https://github.com/aljazceru/awesome-nostr +- **Nostr Resources**: https://nostr.how + +## Reference Files + +For comprehensive NIP details, see: +- **references/nips-overview.md** - Detailed descriptions of all standard NIPs +- **references/event-kinds.md** - Complete event kinds reference +- **references/common-mistakes.md** - Pitfalls and how to avoid them + +## Quick Checklist + +When implementing Nostr: +- [ ] Events have all required fields (id, pubkey, created_at, kind, tags, content, sig) +- [ ] Event IDs calculated correctly (SHA256 of serialization) +- [ ] Signatures verified (schnorr on secp256k1) +- [ ] WebSocket messages properly formatted +- [ ] Filter queries optimized with appropriate limits +- [ ] Handling replaceable events correctly +- [ ] Connected to multiple relays for redundancy +- [ ] Following relevant NIPs for features implemented +- [ ] Private keys never exposed or transmitted +- [ ] Event timestamps validated + +## Official Resources + +- **NIPs Repository**: https://github.com/nostr-protocol/nips +- **Nostr Website**: https://nostr.com +- **Nostr Documentation**: https://nostr.how +- **NIP Status**: https://nostr-nips.com + diff --git a/.claude/skills/nostr/references/common-mistakes.md b/.claude/skills/nostr/references/common-mistakes.md new file mode 100644 index 00000000..569a1442 --- /dev/null +++ b/.claude/skills/nostr/references/common-mistakes.md @@ -0,0 +1,657 @@ +# Common Nostr Implementation Mistakes and How to Avoid Them + +This document highlights frequent errors made when implementing Nostr clients and relays, along with solutions. + +## Event Creation and Signing + +### Mistake 1: Incorrect Event ID Calculation + +**Problem**: Wrong serialization order or missing fields when calculating SHA256. + +**Correct Serialization**: +```json +[ + 0, // Must be integer 0 + , // Lowercase hex string + , // Unix timestamp integer + , // Integer + , // Array of arrays + // String +] +``` + +**Common errors**: +- Using string "0" instead of integer 0 +- Including `id` or `sig` fields in serialization +- Wrong field order +- Not using compact JSON (no spaces) +- Using uppercase hex + +**Fix**: Serialize exactly as shown, compact JSON, SHA256 the UTF-8 bytes. + +### Mistake 2: Wrong Signature Algorithm + +**Problem**: Using ECDSA instead of Schnorr signatures. + +**Correct**: +- Use Schnorr signatures (BIP-340) +- Curve: secp256k1 +- Sign the 32-byte event ID + +**Libraries**: +- JavaScript: noble-secp256k1 +- Rust: secp256k1 +- Go: btcsuite/btcd/btcec/v2/schnorr +- Python: secp256k1-py + +### Mistake 3: Invalid created_at Timestamps + +**Problem**: Events with far-future timestamps or very old timestamps. + +**Best practices**: +- Use current Unix time: `Math.floor(Date.now() / 1000)` +- Relays often reject if `created_at > now + 15 minutes` +- Don't backdate events to manipulate ordering + +**Fix**: Always use current time when creating events. + +### Mistake 4: Malformed Tags + +**Problem**: Tags that aren't arrays or have wrong structure. + +**Correct format**: +```json +{ + "tags": [ + ["e", "event-id", "relay-url", "marker"], + ["p", "pubkey", "relay-url"], + ["t", "hashtag"] + ] +} +``` + +**Common errors**: +- Using objects instead of arrays: `{"e": "..."}` ❌ +- Missing inner arrays: `["e", "event-id"]` when nested in tags is wrong +- Wrong nesting depth +- Non-string values (except for specific NIPs) + +### Mistake 5: Not Handling Replaceable Events + +**Problem**: Showing multiple versions of replaceable events. + +**Event types**: +- **Replaceable (10000-19999)**: Same author + kind → replace +- **Parameterized Replaceable (30000-39999)**: Same author + kind + d-tag → replace + +**Fix**: +```javascript +// For replaceable events +const key = `${event.pubkey}:${event.kind}` +if (latestEvents[key]?.created_at < event.created_at) { + latestEvents[key] = event +} + +// For parameterized replaceable events +const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '' +const key = `${event.pubkey}:${event.kind}:${dTag}` +if (latestEvents[key]?.created_at < event.created_at) { + latestEvents[key] = event +} +``` + +## WebSocket Communication + +### Mistake 6: Not Handling EOSE + +**Problem**: Loading indicators never finish or show wrong state. + +**Solution**: +```javascript +const receivedEvents = new Set() +let eoseReceived = false + +ws.onmessage = (msg) => { + const [type, ...rest] = JSON.parse(msg.data) + + if (type === 'EVENT') { + const [subId, event] = rest + receivedEvents.add(event.id) + displayEvent(event) + } + + if (type === 'EOSE') { + eoseReceived = true + hideLoadingSpinner() + } +} +``` + +### Mistake 7: Not Closing Subscriptions + +**Problem**: Memory leaks and wasted bandwidth from unclosed subscriptions. + +**Fix**: Always send CLOSE when done: +```javascript +ws.send(JSON.stringify(['CLOSE', subId])) +``` + +**Best practices**: +- Close when component unmounts +- Close before opening new subscription with same ID +- Use unique subscription IDs +- Track active subscriptions + +### Mistake 8: Ignoring OK Messages + +**Problem**: Not knowing if events were accepted or rejected. + +**Solution**: +```javascript +ws.onmessage = (msg) => { + const [type, eventId, accepted, message] = JSON.parse(msg.data) + + if (type === 'OK') { + if (!accepted) { + console.error(`Event ${eventId} rejected: ${message}`) + handleRejection(eventId, message) + } + } +} +``` + +**Common rejection reasons**: +- `pow:` - Insufficient proof of work +- `blocked:` - Pubkey or content blocked +- `rate-limited:` - Too many requests +- `invalid:` - Failed validation + +### Mistake 9: Sending Events Before WebSocket Ready + +**Problem**: Events lost because WebSocket not connected. + +**Fix**: +```javascript +const sendWhenReady = (ws, message) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(message) + } else { + ws.addEventListener('open', () => ws.send(message), { once: true }) + } +} +``` + +### Mistake 10: Not Handling WebSocket Disconnections + +**Problem**: App breaks when relay goes offline. + +**Solution**: Implement reconnection with exponential backoff: +```javascript +let reconnectDelay = 1000 +const maxDelay = 30000 + +const connect = () => { + const ws = new WebSocket(relayUrl) + + ws.onclose = () => { + setTimeout(() => { + reconnectDelay = Math.min(reconnectDelay * 2, maxDelay) + connect() + }, reconnectDelay) + } + + ws.onopen = () => { + reconnectDelay = 1000 // Reset on successful connection + resubscribe() // Re-establish subscriptions + } +} +``` + +## Filter Queries + +### Mistake 11: Overly Broad Filters + +**Problem**: Requesting too many events, overwhelming relay and client. + +**Bad**: +```json +{ + "kinds": [1], + "limit": 10000 +} +``` + +**Good**: +```json +{ + "kinds": [1], + "authors": [""], + "limit": 50, + "since": 1234567890 +} +``` + +**Best practices**: +- Always set reasonable `limit` (50-500) +- Filter by `authors` when possible +- Use `since`/`until` for time ranges +- Be specific with `kinds` +- Multiple smaller queries > one huge query + +### Mistake 12: Not Using Prefix Matching + +**Problem**: Full hex strings in filters unnecessarily. + +**Optimization**: +```json +{ + "ids": ["abc12345"], // 8 chars enough for uniqueness + "authors": ["def67890"] +} +``` + +Relays support prefix matching for `ids` and `authors`. + +### Mistake 13: Duplicate Filter Fields + +**Problem**: Redundant filter conditions. + +**Bad**: +```json +{ + "authors": ["pubkey1", "pubkey1"], + "kinds": [1, 1] +} +``` + +**Good**: +```json +{ + "authors": ["pubkey1"], + "kinds": [1] +} +``` + +Deduplicate filter arrays. + +## Threading and References + +### Mistake 14: Incorrect Thread Structure + +**Problem**: Missing root/reply markers or wrong tag order. + +**Correct reply structure** (NIP-10): +```json +{ + "kind": 1, + "tags": [ + ["e", "", "", "root"], + ["e", "", "", "reply"], + ["p", ""], + ["p", ""] + ] +} +``` + +**Key points**: +- Root event should have "root" marker +- Direct parent should have "reply" marker +- Include `p` tags for all mentioned users +- Relay hints are optional but helpful + +### Mistake 15: Missing p Tags in Replies + +**Problem**: Authors not notified of replies. + +**Fix**: Always add `p` tag for: +- Original author +- Authors mentioned in content +- Authors in the thread chain + +```json +{ + "tags": [ + ["e", "event-id", "", "reply"], + ["p", "original-author"], + ["p", "mentioned-user1"], + ["p", "mentioned-user2"] + ] +} +``` + +### Mistake 16: Not Using Markers + +**Problem**: Ambiguous thread structure. + +**Solution**: Always use markers in `e` tags: +- `root` - Root of thread +- `reply` - Direct parent +- `mention` - Referenced but not replied to + +Without markers, clients must guess thread structure. + +## Relay Management + +### Mistake 17: Relying on Single Relay + +**Problem**: Single point of failure, censorship vulnerability. + +**Solution**: Connect to multiple relays (5-15 common): +```javascript +const relays = [ + 'wss://relay1.com', + 'wss://relay2.com', + 'wss://relay3.com' +] + +const connections = relays.map(url => connect(url)) +``` + +**Best practices**: +- Publish to 3-5 write relays +- Read from 5-10 read relays +- Use NIP-65 for user's preferred relays +- Fall back to NIP-05 relays +- Implement relay rotation on failure + +### Mistake 18: Not Implementing NIP-65 + +**Problem**: Querying wrong relays, missing user's events. + +**Correct flow**: +1. Fetch user's kind `10002` event (relay list) +2. Connect to their read relays to fetch their content +3. Connect to their write relays to send them messages + +```javascript +async function getUserRelays(pubkey) { + // Fetch kind 10002 + const relayList = await fetchEvent({ + kinds: [10002], + authors: [pubkey] + }) + + const readRelays = [] + const writeRelays = [] + + relayList.tags.forEach(([tag, url, mode]) => { + if (tag === 'r') { + if (!mode || mode === 'read') readRelays.push(url) + if (!mode || mode === 'write') writeRelays.push(url) + } + }) + + return { readRelays, writeRelays } +} +``` + +### Mistake 19: Not Respecting Relay Limitations + +**Problem**: Violating relay policies, getting rate limited or banned. + +**Solution**: Fetch and respect NIP-11 relay info: +```javascript +const getRelayInfo = async (relayUrl) => { + const url = relayUrl.replace('wss://', 'https://').replace('ws://', 'http://') + const response = await fetch(url, { + headers: { 'Accept': 'application/nostr+json' } + }) + return response.json() +} + +// Respect limitations +const info = await getRelayInfo(relayUrl) +const maxLimit = info.limitation?.max_limit || 500 +const maxFilters = info.limitation?.max_filters || 10 +``` + +## Security + +### Mistake 20: Exposing Private Keys + +**Problem**: Including nsec in client code, logs, or network requests. + +**Never**: +- Store nsec in localStorage without encryption +- Log private keys +- Send nsec over network +- Display nsec to user unless explicitly requested +- Hard-code private keys + +**Best practices**: +- Use NIP-07 (browser extension) when possible +- Encrypt keys at rest +- Use NIP-46 (remote signing) for web apps +- Warn users when showing nsec + +### Mistake 21: Not Verifying Signatures + +**Problem**: Accepting invalid events, vulnerability to attacks. + +**Always verify**: +```javascript +const verifyEvent = (event) => { + // 1. Verify ID + const calculatedId = sha256(serializeEvent(event)) + if (calculatedId !== event.id) return false + + // 2. Verify signature + const signatureValid = schnorr.verify( + event.sig, + event.id, + event.pubkey + ) + if (!signatureValid) return false + + // 3. Check timestamp + const now = Math.floor(Date.now() / 1000) + if (event.created_at > now + 900) return false // 15 min future + + return true +} +``` + +**Verify before**: +- Displaying to user +- Storing in database +- Using event data for logic + +### Mistake 22: Using NIP-04 Encryption + +**Problem**: Weak encryption, vulnerable to attacks. + +**Solution**: Use NIP-44 instead: +- Modern authenticated encryption +- ChaCha20-Poly1305 AEAD +- Proper key derivation +- Version byte for upgradability + +**Migration**: Update to NIP-44 for all new encrypted messages. + +### Mistake 23: Not Sanitizing Content + +**Problem**: XSS vulnerabilities in displayed content. + +**Solution**: Sanitize before rendering: +```javascript +import DOMPurify from 'dompurify' + +const safeContent = DOMPurify.sanitize(event.content, { + ALLOWED_TAGS: ['b', 'i', 'u', 'a', 'code', 'pre'], + ALLOWED_ATTR: ['href', 'target', 'rel'] +}) +``` + +**Especially critical for**: +- Markdown rendering +- Link parsing +- Image URLs +- User-provided HTML + +## User Experience + +### Mistake 24: Not Caching Events + +**Problem**: Re-fetching same events repeatedly, poor performance. + +**Solution**: Implement event cache: +```javascript +const eventCache = new Map() + +const cacheEvent = (event) => { + eventCache.set(event.id, event) +} + +const getCachedEvent = (eventId) => { + return eventCache.get(eventId) +} +``` + +**Cache strategies**: +- LRU eviction for memory management +- IndexedDB for persistence +- Invalidate replaceable events on update +- Cache metadata (kind 0) aggressively + +### Mistake 25: Not Implementing Optimistic UI + +**Problem**: Slow feeling app, waiting for relay confirmation. + +**Solution**: Show user's events immediately: +```javascript +const publishEvent = async (event) => { + // Immediately show to user + displayEvent(event, { pending: true }) + + // Publish to relays + const results = await Promise.all( + relays.map(relay => relay.publish(event)) + ) + + // Update status based on results + const success = results.some(r => r.accepted) + displayEvent(event, { pending: false, success }) +} +``` + +### Mistake 26: Poor Loading States + +**Problem**: User doesn't know if app is working. + +**Solution**: Clear loading indicators: +- Show spinner until EOSE +- Display "Loading..." placeholder +- Show how many relays responded +- Indicate connection status per relay + +### Mistake 27: Not Handling Large Threads + +**Problem**: Loading entire thread at once, performance issues. + +**Solution**: Implement pagination: +```javascript +const loadThread = async (eventId, cursor = null) => { + const filter = { + "#e": [eventId], + kinds: [1], + limit: 20, + until: cursor + } + + const replies = await fetchEvents(filter) + return { replies, nextCursor: replies[replies.length - 1]?.created_at } +} +``` + +## Testing + +### Mistake 28: Not Testing with Multiple Relays + +**Problem**: App works with one relay but fails with others. + +**Solution**: Test with: +- Fast relays +- Slow relays +- Unreliable relays +- Paid relays (auth required) +- Relays with different NIP support + +### Mistake 29: Not Testing Edge Cases + +**Critical tests**: +- Empty filter results +- WebSocket disconnections +- Malformed events +- Very long content +- Invalid signatures +- Relay errors +- Rate limiting +- Concurrent operations + +### Mistake 30: Not Monitoring Performance + +**Metrics to track**: +- Event verification time +- WebSocket latency per relay +- Events per second processed +- Memory usage (event cache) +- Subscription count +- Failed publishes + +## Best Practices Checklist + +**Event Creation**: +- [ ] Correct serialization for ID +- [ ] Schnorr signatures +- [ ] Current timestamp +- [ ] Valid tag structure +- [ ] Handle replaceable events + +**WebSocket**: +- [ ] Handle EOSE +- [ ] Close subscriptions +- [ ] Process OK messages +- [ ] Check WebSocket state +- [ ] Reconnection logic + +**Filters**: +- [ ] Set reasonable limits +- [ ] Specific queries +- [ ] Deduplicate arrays +- [ ] Use prefix matching + +**Threading**: +- [ ] Use root/reply markers +- [ ] Include all p tags +- [ ] Proper thread structure + +**Relays**: +- [ ] Multiple relays +- [ ] Implement NIP-65 +- [ ] Respect limitations +- [ ] Handle failures + +**Security**: +- [ ] Never expose nsec +- [ ] Verify all signatures +- [ ] Use NIP-44 encryption +- [ ] Sanitize content + +**UX**: +- [ ] Cache events +- [ ] Optimistic UI +- [ ] Loading states +- [ ] Pagination + +**Testing**: +- [ ] Multiple relays +- [ ] Edge cases +- [ ] Monitor performance + +## Resources + +- **nostr-tools**: JavaScript library with best practices +- **rust-nostr**: Rust implementation with strong typing +- **NIPs Repository**: Official specifications +- **Nostr Dev**: Community resources and help + diff --git a/.claude/skills/nostr/references/event-kinds.md b/.claude/skills/nostr/references/event-kinds.md new file mode 100644 index 00000000..8b587dab --- /dev/null +++ b/.claude/skills/nostr/references/event-kinds.md @@ -0,0 +1,361 @@ +# Nostr Event Kinds - Complete Reference + +This document provides a comprehensive list of all standard and commonly-used Nostr event kinds. + +## Standard Event Kinds + +### Core Events (0-999) + +#### Metadata and Profile +- **0**: `Metadata` - User profile information (name, about, picture, etc.) + - Replaceable + - Content: JSON with profile fields + +#### Text Content +- **1**: `Text Note` - Short-form post (like a tweet) + - Regular event (not replaceable) + - Most common event type + +#### Relay Recommendations +- **2**: `Recommend Relay` - Deprecated, use NIP-65 instead + +#### Contact Lists +- **3**: `Contacts` - Following list with optional relay hints + - Replaceable + - Tags: `p` tags for each followed user + +#### Encrypted Messages +- **4**: `Encrypted Direct Message` - Private message (NIP-04, deprecated) + - Regular event + - Use NIP-44 instead for better security + +#### Content Management +- **5**: `Event Deletion` - Request to delete events + - Tags: `e` tags for events to delete + - Only works for own events + +#### Sharing +- **6**: `Repost` - Share another event + - Tags: `e` for reposted event, `p` for original author + - May include original event in content + +#### Reactions +- **7**: `Reaction` - Like, emoji reaction to event + - Content: "+" or emoji + - Tags: `e` for reacted event, `p` for author + +### Channel Events (40-49) + +- **40**: `Channel Creation` - Create a public chat channel +- **41**: `Channel Metadata` - Set channel name, about, picture +- **42**: `Channel Message` - Post message in channel +- **43**: `Channel Hide Message` - Hide a message in channel +- **44**: `Channel Mute User` - Mute a user in channel + +### Regular Events (1000-9999) + +Regular events are never deleted or replaced. All versions are kept. + +- **1000**: `Example regular event` +- **1063**: `File Metadata` (NIP-94) - Metadata for shared files + - Tags: url, MIME type, hash, size, dimensions + +### Replaceable Events (10000-19999) + +Only the latest event of each kind is kept per pubkey. + +- **10000**: `Mute List` - List of muted users/content +- **10001**: `Pin List` - Pinned events +- **10002**: `Relay List Metadata` (NIP-65) - User's preferred relays + - Critical for routing + - Tags: `r` with relay URLs and read/write markers + +### Ephemeral Events (20000-29999) + +Not stored by relays, only forwarded once. + +- **20000**: `Example ephemeral event` +- **21000**: `Typing Indicator` - User is typing +- **22242**: `Client Authentication` (NIP-42) - Auth response to relay + +### Parameterized Replaceable Events (30000-39999) + +Replaced based on `d` tag value. + +#### Lists (30000-30009) +- **30000**: `Categorized People List` - Custom people lists + - `d` tag: list identifier + - `p` tags: people in list + +- **30001**: `Categorized Bookmark List` - Bookmark collections + - `d` tag: list identifier + - `e` or `a` tags: bookmarked items + +- **30008**: `Badge Definition` (NIP-58) - Define a badge/achievement + - `d` tag: badge ID + - Tags: name, description, image + +- **30009**: `Profile Badges` (NIP-58) - Badges displayed on profile + - `d` tag: badge ID + - `e` or `a` tags: badge awards + +#### Long-form Content (30023) +- **30023**: `Long-form Article` (NIP-23) - Blog post, article + - `d` tag: article identifier (slug) + - Tags: title, summary, published_at, image + - Content: Markdown + +#### Application Data (30078) +- **30078**: `Application-specific Data` (NIP-78) + - `d` tag: app-name:data-key + - Content: app-specific data (may be encrypted) + +#### Other Parameterized Replaceables +- **31989**: `Application Handler Information` (NIP-89) + - Declares app can handle certain event kinds + +- **31990**: `Handler Recommendation` (NIP-89) + - User's preferred apps for event kinds + +## Special Event Kinds + +### Authentication & Signing +- **22242**: `Client Authentication` - Prove key ownership to relay +- **24133**: `Nostr Connect` - Remote signer protocol (NIP-46) + +### Lightning & Payments +- **9734**: `Zap Request` (NIP-57) - Request Lightning payment + - Not published to regular relays + - Sent to LNURL provider + +- **9735**: `Zap Receipt` (NIP-57) - Proof of Lightning payment + - Published by LNURL provider + - Proves zap was paid + +- **23194**: `Wallet Request` (NIP-47) - Request wallet operation +- **23195**: `Wallet Response` (NIP-47) - Response to wallet request + +### Content & Annotations +- **1984**: `Reporting` (NIP-56) - Report content/users + - Tags: reason (spam, illegal, etc.) + +- **9802**: `Highlights` (NIP-84) - Highlight text + - Content: highlighted text + - Tags: context, source event + +### Badges & Reputation +- **8**: `Badge Award` (NIP-58) - Award a badge to someone + - Tags: `a` for badge definition, `p` for recipient + +### Generic Events +- **16**: `Generic Repost` (NIP-18) - Repost any event kind + - More flexible than kind 6 + +- **27235**: `HTTP Auth` (NIP-98) - Authenticate HTTP requests + - Tags: URL, method + +## Event Kind Ranges Summary + +| Range | Type | Behavior | Examples | +|-------|------|----------|----------| +| 0-999 | Core | Varies | Metadata, notes, reactions | +| 1000-9999 | Regular | Immutable, all kept | File metadata | +| 10000-19999 | Replaceable | Only latest kept | Mute list, relay list | +| 20000-29999 | Ephemeral | Not stored | Typing, presence | +| 30000-39999 | Parameterized Replaceable | Replaced by `d` tag | Articles, lists, badges | + +## Event Lifecycle + +### Regular Events (1000-9999) +``` +Event A published → Stored +Event A' published → Both A and A' stored +``` + +### Replaceable Events (10000-19999) +``` +Event A published → Stored +Event A' published (same kind, same pubkey) → A deleted, A' stored +``` + +### Parameterized Replaceable Events (30000-39999) +``` +Event A (d="foo") published → Stored +Event B (d="bar") published → Both stored (different d) +Event A' (d="foo") published → A deleted, A' stored (same d) +``` + +### Ephemeral Events (20000-29999) +``` +Event A published → Forwarded to subscribers, NOT stored +``` + +## Common Patterns + +### Metadata (Kind 0) +```json +{ + "kind": 0, + "content": "{\"name\":\"Alice\",\"about\":\"Nostr user\",\"picture\":\"https://...\",\"nip05\":\"alice@example.com\"}", + "tags": [] +} +``` + +### Text Note (Kind 1) +```json +{ + "kind": 1, + "content": "Hello Nostr!", + "tags": [ + ["t", "nostr"], + ["t", "hello"] + ] +} +``` + +### Reply (Kind 1 with thread tags) +```json +{ + "kind": 1, + "content": "Great post!", + "tags": [ + ["e", "", "", "root"], + ["e", "", "", "reply"], + ["p", ""] + ] +} +``` + +### Reaction (Kind 7) +```json +{ + "kind": 7, + "content": "+", + "tags": [ + ["e", ""], + ["p", ""], + ["k", "1"] + ] +} +``` + +### Long-form Article (Kind 30023) +```json +{ + "kind": 30023, + "content": "# My Article\n\nContent here...", + "tags": [ + ["d", "my-article-slug"], + ["title", "My Article"], + ["summary", "This is about..."], + ["published_at", "1234567890"], + ["t", "nostr"], + ["image", "https://..."] + ] +} +``` + +### Relay List (Kind 10002) +```json +{ + "kind": 10002, + "content": "", + "tags": [ + ["r", "wss://relay1.com"], + ["r", "wss://relay2.com", "write"], + ["r", "wss://relay3.com", "read"] + ] +} +``` + +### Zap Request (Kind 9734) +```json +{ + "kind": 9734, + "content": "", + "tags": [ + ["relays", "wss://relay1.com", "wss://relay2.com"], + ["amount", "21000"], + ["lnurl", "lnurl..."], + ["p", ""], + ["e", ""] + ] +} +``` + +### File Metadata (Kind 1063) +```json +{ + "kind": 1063, + "content": "My photo from the trip", + "tags": [ + ["url", "https://cdn.example.com/image.jpg"], + ["m", "image/jpeg"], + ["x", "abc123..."], + ["size", "524288"], + ["dim", "1920x1080"], + ["blurhash", "LEHV6n..."] + ] +} +``` + +### Report (Kind 1984) +```json +{ + "kind": 1984, + "content": "This is spam", + "tags": [ + ["e", "", ""], + ["p", ""], + ["report", "spam"] + ] +} +``` + +## Future Event Kinds + +The event kind space is open-ended. New NIPs may define new event kinds. + +**Guidelines for new event kinds**: +1. Use appropriate range for desired behavior +2. Document in a NIP +3. Implement in at least 2 clients and 1 relay +4. Ensure backwards compatibility +5. Don't overlap with existing kinds + +**Custom event kinds**: +- Applications can use undefined event kinds +- Document behavior for interoperability +- Consider proposing as a NIP if useful broadly + +## Event Kind Selection Guide + +**Choose based on lifecycle needs**: + +- **Regular (1000-9999)**: When you need history + - User posts, comments, reactions + - Payment records, receipts + - Immutable records + +- **Replaceable (10000-19999)**: When you need latest state + - User settings, preferences + - Mute/block lists + - Current status + +- **Ephemeral (20000-29999)**: When you need real-time only + - Typing indicators + - Online presence + - Temporary notifications + +- **Parameterized Replaceable (30000-39999)**: When you need multiple latest states + - Articles (one per slug) + - Product listings (one per product ID) + - Configuration sets (one per setting name) + +## References + +- NIPs Repository: https://github.com/nostr-protocol/nips +- NIP-16: Event Treatment +- NIP-01: Event structure +- Various feature NIPs for specific kinds + diff --git a/.claude/skills/nostr/references/nips-overview.md b/.claude/skills/nostr/references/nips-overview.md new file mode 100644 index 00000000..bcf3e96a --- /dev/null +++ b/.claude/skills/nostr/references/nips-overview.md @@ -0,0 +1,1170 @@ +# Nostr Implementation Possibilities (NIPs) - Complete Overview + +This document provides detailed descriptions of all standard NIPs from the nostr-protocol/nips repository. + +## Core Protocol NIPs + +### NIP-01: Basic Protocol Flow Description + +**Status**: Mandatory for all implementations + +The foundational NIP that defines the entire Nostr protocol. + +#### Events + +Events are the only object type in Nostr. Structure: + +```json +{ + "id": "<32-bytes lowercase hex>", + "pubkey": "<32-bytes lowercase hex>", + "created_at": "", + "kind": "", + "tags": [["", "", ...]], + "content": "", + "sig": "<64-bytes hex>" +} +``` + +**Event ID Calculation**: +1. Serialize to JSON array: `[0, pubkey, created_at, kind, tags, content]` +2. UTF-8 encode +3. Calculate SHA256 hash +4. Result is the event ID + +**Signature**: +- Schnorr signature of the event ID +- Uses secp256k1 curve +- 64-byte hex-encoded + +#### Communication Protocol + +All communication happens over WebSocket. + +**Client Messages**: + +1. `["EVENT", ]` - Publish event +2. `["REQ", , , ...]` - Subscribe +3. `["CLOSE", ]` - Unsubscribe + +**Relay Messages**: + +1. `["EVENT", , ]` - Send event +2. `["OK", , , ]` - Command result +3. `["EOSE", ]` - End of stored events +4. `["CLOSED", , ]` - Forced close +5. `["NOTICE", ]` - Human-readable notice + +#### Filters + +Filter object fields (all optional): +- `ids`: List of event IDs (prefix match) +- `authors`: List of pubkeys (prefix match) +- `kinds`: List of event kinds +- `#`: Tag queries +- `since`: Unix timestamp (events after) +- `until`: Unix timestamp (events before) +- `limit`: Maximum events to return + +A filter matches if ALL conditions are met. Within arrays, conditions are ORed. + +#### Basic Event Kinds + +- `0`: Metadata (user profile) +- `1`: Text note +- `2`: Recommend relay (deprecated) + +### NIP-02: Contact List and Petnames + +**Status**: Widely implemented + +Defines event kind `3` for user contact lists (following lists). + +**Format**: +```json +{ + "kind": 3, + "tags": [ + ["p", "", "", ""] + ], + "content": "" +} +``` + +**Characteristics**: +- Replaceable event (latest version is authoritative) +- Each `p` tag is a followed user +- Relay URL (optional): where to find this user +- Petname (optional): user's chosen name for contact +- Content may contain JSON relay list (deprecated, use NIP-65) + +**Usage**: +- Clients fetch kind 3 to build following list +- Always replace old version with new +- Use for social graph discovery + +### NIP-03: OpenTimestamps Attestations + +**Status**: Optional + +Allows embedding OpenTimestamps proofs in events. + +**Format**: +```json +{ + "tags": [ + ["ots", ""] + ] +} +``` + +Used to prove an event existed at a specific time via Bitcoin blockchain timestamps. + +### NIP-04: Encrypted Direct Messages + +**Status**: Deprecated (use NIP-44) + +Event kind `4` for encrypted private messages. + +**Encryption**: +- ECDH shared secret between sender/receiver +- AES-256-CBC encryption +- Base64 encoded result + +**Format**: +```json +{ + "kind": 4, + "tags": [ + ["p", ""] + ], + "content": "" +} +``` + +**Security Issues**: +- Vulnerable to certain attacks +- No forward secrecy +- Use NIP-44 instead + +### NIP-05: Mapping Nostr Keys to DNS-based Internet Identifiers + +**Status**: Widely implemented + +Allows verification of identity via domain names (like email addresses). + +**Format**: `name@domain.com` + +**Implementation**: + +1. User adds `"nip05": "alice@example.com"` to metadata (kind 0) +2. Domain serves `/.well-known/nostr.json`: + +```json +{ + "names": { + "alice": "" + }, + "relays": { + "": ["wss://relay1.com", "wss://relay2.com"] + } +} +``` + +3. Clients verify by fetching and checking pubkey match + +**Benefits**: +- Human-readable identifiers +- Domain-based verification +- Optional relay hints +- Spam prevention (verified users) + +### NIP-06: Basic Key Derivation from Mnemonic Seed Phrase + +**Status**: Optional + +Derives Nostr keys from BIP39 mnemonic phrases. + +**Derivation Path**: `m/44'/1237'/0'/0/0` +- 1237 is the coin type for Nostr +- Allows HD wallet-style key management + +**Benefits**: +- Backup with 12/24 words +- Multiple accounts from one seed +- Compatible with BIP39 tools + +### NIP-07: window.nostr Capability for Web Browsers + +**Status**: Browser extension standard + +Defines browser API for Nostr key management. + +**API Methods**: + +```javascript +window.nostr.getPublicKey(): Promise +window.nostr.signEvent(event): Promise +window.nostr.getRelays(): Promise<{[url]: {read: boolean, write: boolean}}> +window.nostr.nip04.encrypt(pubkey, plaintext): Promise +window.nostr.nip04.decrypt(pubkey, ciphertext): Promise +``` + +**Usage**: +- Web apps request signatures from extension +- Private keys never leave extension +- User approves each action +- Popular extensions: nos2x, Alby, Flamingo + +### NIP-08: Handling Mentions + +**Status**: Core convention + +Defines how to mention users and events in notes. + +**Format**: +- Add `p` or `e` tags for mentions +- Reference in content with `#[index]` + +```json +{ + "kind": 1, + "tags": [ + ["p", "<pubkey>", "<relay>"], + ["e", "<event-id>", "<relay>"] + ], + "content": "Hello #[0], check out #[1]" +} +``` + +Clients replace `#[0]`, `#[1]` with user-friendly displays. + +### NIP-09: Event Deletion + +**Status**: Widely implemented + +Event kind `5` requests deletion of events. + +**Format**: +```json +{ + "kind": 5, + "tags": [ + ["e", "<event-id-to-delete>"], + ["e", "<another-event-id>"] + ], + "content": "Reason for deletion (optional)" +} +``` + +**Behavior**: +- Only author can delete their events +- Relays SHOULD delete referenced events +- Not guaranteed (relays may ignore) +- Some clients show deletion notice + +### NIP-10: Text Note References (Reply, Threads) + +**Status**: Core threading standard + +Conventions for `e` and `p` tags in threaded conversations. + +**Markers**: +- `root`: The root event of the thread +- `reply`: Direct parent being replied to +- `mention`: Mentioned but not replied to + +**Format**: +```json +{ + "kind": 1, + "tags": [ + ["e", "<root-event-id>", "<relay>", "root"], + ["e", "<parent-event-id>", "<relay>", "reply"], + ["e", "<mentioned-event-id>", "<relay>", "mention"], + ["p", "<author1-pubkey>"], + ["p", "<author2-pubkey>"] + ] +} +``` + +**Best Practices**: +- Always include root marker for thread context +- Include reply marker for direct parent +- Add p tags for all mentioned users +- Maintains thread integrity + +### NIP-11: Relay Information Document + +**Status**: Standard + +HTTP endpoint for relay metadata. + +**Implementation**: +- HTTP GET to relay URL (not WebSocket) +- Accept header: `application/nostr+json` + +**Response Example**: +```json +{ + "name": "Example Relay", + "description": "A Nostr relay", + "pubkey": "<admin-pubkey>", + "contact": "admin@example.com", + "supported_nips": [1, 2, 9, 11, 12, 15, 16, 20, 22], + "software": "git+https://github.com/...", + "version": "1.0.0", + "limitation": { + "max_message_length": 16384, + "max_subscriptions": 20, + "max_filters": 100, + "max_limit": 5000, + "max_subid_length": 100, + "min_prefix": 4, + "max_event_tags": 100, + "max_content_length": 8196, + "min_pow_difficulty": 30, + "auth_required": false, + "payment_required": false + }, + "relay_countries": ["US", "CA"], + "language_tags": ["en", "es"], + "tags": ["adult-content", "no-spam"], + "posting_policy": "https://example.com/policy", + "payments_url": "https://example.com/pay", + "fees": { + "admission": [{"amount": 5000000, "unit": "msats"}], + "subscription": [{"amount": 1000000, "unit": "msats", "period": 2592000}], + "publication": [] + }, + "icon": "https://example.com/icon.png" +} +``` + +**Usage**: +- Clients discover relay capabilities +- Check NIP support before using features +- Display relay info to users +- Respect limitations + +### NIP-12: Generic Tag Queries + +**Status**: Core functionality + +Extends filtering to support any single-letter tag. + +**Syntax**: `#<letter>: [<value>, ...]` + +**Examples**: +```json +{ + "#t": ["bitcoin", "nostr"], + "#p": ["pubkey1", "pubkey2"], + "#e": ["eventid1"] +} +``` + +Matches events with specified tag values. + +### NIP-13: Proof of Work + +**Status**: Spam prevention + +Requires computational work for event publication. + +**Implementation**: +- Add `nonce` tag: `["nonce", "<number>", "<target-difficulty>"]` +- Hash event ID until leading zero bits >= difficulty +- Increment nonce until condition met + +**Example**: +```json +{ + "tags": [ + ["nonce", "12345", "20"] + ], + "id": "00000abcd..." // 20+ leading zero bits +} +``` + +**Difficulty Levels**: +- 0-10: Very easy +- 20: Moderate +- 30+: Difficult +- 40+: Very difficult + +Relays can require minimum PoW for acceptance. + +### NIP-14: Subject Tag + +**Status**: Convenience + +Adds `subject` tag for event titles/subjects. + +**Format**: +```json +{ + "tags": [ + ["subject", "My Post Title"] + ] +} +``` + +Used for long-form content, discussions, emails-style messages. + +### NIP-15: End of Stored Events (EOSE) + +**Status**: Core protocol + +Relay sends `EOSE` after sending all stored events matching a subscription. + +**Format**: `["EOSE", <subscription_id>]` + +**Usage**: +- Clients know when historical events are complete +- Can show "loading" state until EOSE +- New events after EOSE are real-time + +### NIP-16: Event Treatment + +**Status**: Event lifecycle + +Defines three event categories: + +1. **Regular Events** (1000-9999): + - Immutable + - All versions kept + - Examples: notes, reactions + +2. **Replaceable Events** (10000-19999): + - Only latest kept + - Same author + kind → replace + - Examples: metadata, contacts + +3. **Ephemeral Events** (20000-29999): + - Not stored + - Forwarded once + - Examples: typing indicators, presence + +4. **Parameterized Replaceable Events** (30000-39999): + - Replaced based on `d` tag + - Same author + kind + d-tag → replace + - Examples: long-form posts, product listings + +### NIP-18: Reposts + +**Status**: Social feature + +Event kind `6` for reposting/sharing events. + +**Format**: +```json +{ + "kind": 6, + "tags": [ + ["e", "<reposted-event-id>", "<relay>"], + ["p", "<original-author-pubkey>"] + ], + "content": "" // or reposted event JSON +} +``` + +**Generic Repost** (kind 16): +- Can repost any event kind +- Preserves original context + +### NIP-19: bech32-encoded Entities + +**Status**: Widely implemented + +Human-readable encodings for Nostr entities. + +**Formats**: + +1. **npub**: Public key + - `npub1xyz...` + - Safer to share than hex + +2. **nsec**: Private key (SENSITIVE!) + - `nsec1xyz...` + - Never share publicly + +3. **note**: Event ID + - `note1xyz...` + - Links to specific events + +4. **nprofile**: Profile with hints + - Includes pubkey + relay URLs + - Better discovery + +5. **nevent**: Event with hints + - Includes event ID + relay URLs + author + - Reliable event fetching + +6. **naddr**: Replaceable event coordinate + - Includes kind + pubkey + d-tag + relays + - For parameterized replaceable events + +**Usage**: +- Use for sharing/displaying identifiers +- Clients should support all formats +- Always use npub/nsec instead of hex when possible + +### NIP-20: Command Results + +**Status**: Core protocol + +Defines `OK` message format from relays. + +**Format**: `["OK", <event_id>, <accepted>, <message>]` + +**Examples**: +```json +["OK", "abc123...", true, ""] +["OK", "def456...", false, "invalid: signature verification failed"] +["OK", "ghi789...", false, "pow: difficulty too low"] +["OK", "jkl012...", false, "rate-limited: slow down"] +``` + +**Common Rejection Prefixes**: +- `duplicate:` - Event already received +- `pow:` - Insufficient proof of work +- `blocked:` - Pubkey or content blocked +- `rate-limited:` - Too many requests +- `invalid:` - Event validation failed +- `error:` - Server error + +### NIP-21: nostr: URI Scheme + +**Status**: Standard linking + +Defines `nostr:` URI scheme for deep linking. + +**Format**: +- `nostr:npub1...` +- `nostr:note1...` +- `nostr:nevent1...` +- `nostr:nprofile1...` +- `nostr:naddr1...` + +**Usage**: +- Clickable links in web/mobile +- Cross-app navigation +- QR codes + +### NIP-22: Event created_at Limits + +**Status**: Relay policy + +Relays may reject events with timestamps too far in past/future. + +**Recommendations**: +- Reject events created_at > 15 minutes in future +- Reject very old events (relay-specific) +- Prevents timestamp manipulation + +### NIP-23: Long-form Content + +**Status**: Blog/article support + +Event kind `30023` for long-form content (articles, blogs). + +**Format**: +```json +{ + "kind": 30023, + "tags": [ + ["d", "<unique-identifier>"], + ["title", "Article Title"], + ["summary", "Brief description"], + ["published_at", "<unix-timestamp>"], + ["t", "tag1"], ["t", "tag2"], + ["image", "https://..."] + ], + "content": "Markdown content..." +} +``` + +**Characteristics**: +- Parameterized replaceable (by `d` tag) +- Content in Markdown +- Rich metadata +- Can be edited (updates replace) + +### NIP-25: Reactions + +**Status**: Widely implemented + +Event kind `7` for reactions to events (likes, emoji reactions). + +**Format**: +```json +{ + "kind": 7, + "tags": [ + ["e", "<reacted-event-id>"], + ["p", "<event-author-pubkey>"], + ["k", "<reacted-event-kind>"] + ], + "content": "+" // or emoji +} +``` + +**Content Values**: +- `+`: Like/upvote +- `-`: Dislike (discouraged) +- Emoji: 👍, ❤️, 😂, etc. +- Custom reactions + +**Client Display**: +- Count reactions per event +- Group by emoji +- Show who reacted + +### NIP-26: Delegated Event Signing + +**Status**: Advanced delegation + +Allows delegating event signing to another key. + +**Use Cases**: +- Bot accounts posting for user +- Temporary keys for devices +- Service providers posting on behalf + +**Implementation**: +- Delegation token in tags +- Limits by kind, time range +- Original author still verifiable + +### NIP-27: Text Note References + +**Status**: Convenience + +Shortcuts for mentioning entities inline. + +**Format**: +- `nostr:npub1...` → user mention +- `nostr:note1...` → event reference +- `nostr:nevent1...` → event with context + +Clients render as clickable links. + +### NIP-28: Public Chat (Channels) + +**Status**: Channel support + +Event kinds for public chat channels. + +**Event Kinds**: +- `40`: Create channel +- `41`: Set channel metadata +- `42`: Create message +- `43`: Hide message +- `44`: Mute user + +**Channel Creation (kind 40)**: +```json +{ + "kind": 40, + "content": "{\"name\": \"Bitcoin\", \"about\": \"Discussion\", \"picture\": \"url\"}" +} +``` + +**Channel Message (kind 42)**: +```json +{ + "kind": 42, + "tags": [ + ["e", "<channel-id>", "<relay>", "root"] + ], + "content": "Hello channel!" +} +``` + +### NIP-33: Parameterized Replaceable Events + +**Status**: Core feature + +Event kinds 30000-39999 are replaceable by `d` tag. + +**Format**: +```json +{ + "kind": 30000, + "tags": [ + ["d", "<identifier>"] + ] +} +``` + +**Replacement Rule**: +- Same author + kind + d-tag → replace old event +- Different d-tag → separate events +- No d-tag → treated as `d` = "" + +**Coordinate Reference**: +`<kind>:<pubkey>:<d-value>` + +**Use Cases**: +- Product catalogs (each product = d-tag) +- Article revisions (article slug = d-tag) +- Configuration settings (setting name = d-tag) + +### NIP-36: Sensitive Content Warning + +**Status**: Content moderation + +Tags for marking sensitive/NSFW content. + +**Format**: +```json +{ + "tags": [ + ["content-warning", "nudity"], + ["content-warning", "violence"] + ] +} +``` + +Clients can hide/blur until user confirms. + +### NIP-39: External Identities + +**Status**: Identity verification + +Links Nostr identity to external platforms. + +**Format (in kind 0 metadata)**: +```json +{ + "kind": 0, + "content": "{\"identities\": [{\"platform\": \"github\", \"username\": \"alice\", \"proof\": \"url\"}]}" +} +``` + +**Supported Platforms**: +- GitHub +- Twitter +- Mastodon +- Matrix +- Telegram + +### NIP-40: Expiration Timestamp + +**Status**: Ephemeral content + +Tag for auto-expiring events. + +**Format**: +```json +{ + "tags": [ + ["expiration", "<unix-timestamp>"] + ] +} +``` + +Relays should delete event after expiration time. + +### NIP-42: Authentication of Clients to Relays + +**Status**: Access control + +Relays can require client authentication. + +**Flow**: +1. Relay sends: `["AUTH", "<challenge>"]` +2. Client creates kind `22242` event: +```json +{ + "kind": 22242, + "tags": [ + ["relay", "<relay-url>"], + ["challenge", "<challenge-string>"] + ], + "created_at": <now> +} +``` +3. Client sends: `["AUTH", <signed-event>]` +4. Relay verifies signature and challenge + +**Benefits**: +- Spam prevention +- Access control +- Rate limiting per user +- Paid relays + +### NIP-44: Encrypted Payloads (Versioned) + +**Status**: Modern encryption + +Improved encryption replacing NIP-04. + +**Algorithm**: +- ECDH shared secret +- ChaCha20-Poly1305 AEAD +- Version byte for upgradability +- Salt for key derivation + +**Security Improvements**: +- Authenticated encryption +- Better key derivation +- Version support +- Resistance to padding oracle attacks + +**Format**: +``` +<version-byte><encrypted-payload> +``` + +Base64 encode for `content` field. + +### NIP-45: Event Counts + +**Status**: Statistics + +Request for event counts matching filters. + +**Client Request**: +```json +["COUNT", <subscription_id>, <filters>] +``` + +**Relay Response**: +```json +["COUNT", <subscription_id>, {"count": 123, "approximate": false}] +``` + +**Usage**: +- Display follower counts +- Show engagement metrics +- Statistics dashboards + +### NIP-46: Nostr Connect (Remote Signing) + +**Status**: Remote signer protocol + +Protocol for remote key management and signing. + +**Architecture**: +- Signer: Holds private key +- Client: Requests signatures +- Communication via Nostr events + +**Use Cases**: +- Mobile app delegates to desktop signer +- Browser extension as signer +- Hardware wallet integration +- Multi-device key sharing + +### NIP-47: Wallet Connect + +**Status**: Lightning integration + +Protocol for connecting Lightning wallets to Nostr apps. + +**Commands**: +- `pay_invoice` +- `get_balance` +- `get_info` +- `make_invoice` +- `lookup_invoice` + +Enables in-app Lightning payments. + +### NIP-50: Search Capability + +**Status**: Optional + +Full-text search in filter queries. + +**Format**: +```json +{ + "search": "bitcoin nostr" +} +``` + +**Implementation**: +- Relay-specific behavior +- May search content, tags, etc. +- Not standardized ranking + +### NIP-51: Lists + +**Status**: Curation + +Event kinds for various list types. + +**List Kinds**: +- `30000`: Categorized people list +- `30001`: Categorized bookmarks +- `10000`: Mute list +- `10001`: Pin list + +**Format**: +```json +{ + "kind": 30000, + "tags": [ + ["d", "my-list"], + ["p", "<pubkey>", "<relay>", "<petname>"], + ["t", "<category>"] + ] +} +``` + +### NIP-56: Reporting + +**Status**: Moderation + +Event kind `1984` for reporting content. + +**Format**: +```json +{ + "kind": 1984, + "tags": [ + ["e", "<event-id>", "<relay>"], + ["p", "<pubkey>"], + ["report", "spam"] // or "nudity", "profanity", "illegal", "impersonation" + ], + "content": "Additional details" +} +``` + +Used by relays and clients for moderation. + +### NIP-57: Lightning Zaps + +**Status**: Widely implemented + +Protocol for Lightning tips with proof. + +**Flow**: +1. Get user's Lightning address (from metadata) +2. Fetch LNURL data +3. Create zap request (kind `9734`) +4. Pay invoice +5. Relay publishes zap receipt (kind `9735`) + +**Zap Request (kind 9734)**: +```json +{ + "kind": 9734, + "tags": [ + ["p", "<recipient-pubkey>"], + ["amount", "<millisats>"], + ["relays", "relay1", "relay2"], + ["e", "<event-id>"] // if zapping event + ] +} +``` + +**Zap Receipt (kind 9735)**: +Published by LNURL provider, proves payment. + +### NIP-58: Badges + +**Status**: Reputation system + +Award and display badges (achievements, credentials). + +**Event Kinds**: +- `30008`: Badge definition +- `30009`: Profile badges +- `8`: Badge award + +**Badge Definition**: +```json +{ + "kind": 30008, + "tags": [ + ["d", "badge-id"], + ["name", "Badge Name"], + ["description", "What this means"], + ["image", "url"], + ["thumb", "thumbnail-url"] + ] +} +``` + +### NIP-65: Relay List Metadata + +**Status**: Critical for routing + +Event kind `10002` for user's relay preferences. + +**Format**: +```json +{ + "kind": 10002, + "tags": [ + ["r", "wss://relay1.com"], + ["r", "wss://relay2.com", "write"], + ["r", "wss://relay3.com", "read"] + ] +} +``` + +**Usage**: +- Clients discover where to fetch user's events (read) +- Clients know where to send events for user (write) +- Optimizes relay connections +- Reduces bandwidth + +**Best Practice**: +- Always check NIP-65 before querying +- Fall back to NIP-05 relays if no NIP-65 +- Cache relay lists + +### NIP-78: App-Specific Data + +**Status**: Application storage + +Event kind `30078` for arbitrary app data. + +**Format**: +```json +{ + "kind": 30078, + "tags": [ + ["d", "<app-name>:<data-key>"] + ], + "content": "<encrypted-or-public-data>" +} +``` + +**Use Cases**: +- App settings +- Client-specific cache +- User preferences +- Draft posts + +### NIP-84: Highlights + +**Status**: Annotation + +Event kind `9802` for highlighting content. + +**Format**: +```json +{ + "kind": 9802, + "tags": [ + ["e", "<event-id>"], + ["context", "surrounding text..."], + ["a", "<article-coordinate>"] + ], + "content": "highlighted portion" +} +``` + +Like a highlighter pen for web content. + +### NIP-89: Application Handlers + +**Status**: App discovery + +Advertise and discover apps that handle specific event kinds. + +**Format (kind 31989)**: +```json +{ + "kind": 31989, + "tags": [ + ["k", "1"], // handles kind 1 + ["web", "https://app.com/<bech32>"], + ["ios", "app-scheme://<bech32>"], + ["android", "app-package://<bech32>"] + ] +} +``` + +**Kind 31990**: User's preferred handlers + +### NIP-94: File Metadata + +**Status**: File sharing + +Event kind `1063` for file metadata. + +**Format**: +```json +{ + "kind": 1063, + "tags": [ + ["url", "https://..."], + ["m", "image/jpeg"], // MIME type + ["x", "<sha256-hash>"], + ["size", "123456"], + ["dim", "1920x1080"], + ["magnet", "magnet:..."], + ["blurhash", "..."] + ], + "content": "Description" +} +``` + +**Use Cases**: +- Images, videos, audio +- Documents +- Torrents +- IPFS files + +### NIP-96: HTTP File Storage Integration + +**Status**: File hosting + +HTTP API for file uploads/downloads. + +**Endpoints**: +- `GET /.well-known/nostr/nip96.json` - Server info +- `POST /upload` - Upload file +- `DELETE /delete` - Delete file + +**Upload Response**: +Returns kind `1063` event data for the file. + +### NIP-98: HTTP Auth + +**Status**: API authentication + +Use Nostr events for HTTP API auth. + +**Flow**: +1. Create kind `27235` event with: + - `u` tag: API URL + - `method` tag: HTTP method +2. Add `Authorization: Nostr <base64-event>` header +3. Server verifies signature + +**Benefits**: +- No passwords +- Cryptographic authentication +- Works with Nostr keys + +## Summary of Key NIPs by Category + +### Essential (All implementations) +- NIP-01, NIP-02, NIP-10, NIP-19 + +### Social Features +- NIP-25 (reactions), NIP-18 (reposts), NIP-23 (long-form), NIP-28 (channels) + +### Identity & Discovery +- NIP-05 (verification), NIP-39 (external identities), NIP-65 (relay lists) + +### Security & Privacy +- NIP-04 (deprecated encryption), NIP-44 (modern encryption), NIP-42 (auth), NIP-13 (PoW) + +### Lightning Integration +- NIP-47 (wallet connect), NIP-57 (zaps) + +### Content & Moderation +- NIP-56 (reporting), NIP-36 (content warnings), NIP-09 (deletion) + +### Advanced Features +- NIP-33 (parameterized replaceable), NIP-46 (remote signing), NIP-50 (search) + diff --git a/.claude/skills/react/README.md b/.claude/skills/react/README.md new file mode 100644 index 00000000..9144da87 --- /dev/null +++ b/.claude/skills/react/README.md @@ -0,0 +1,119 @@ +# React 19 Skill + +A comprehensive Claude skill for working with React 19, including hooks, components, server components, and modern React architecture. + +## Contents + +### Main Skill File +- **SKILL.md** - Main skill document with React 19 fundamentals, hooks, components, and best practices + +### References +- **hooks-quick-reference.md** - Quick reference for all React hooks with examples +- **server-components.md** - Complete guide to React Server Components and Server Functions +- **performance.md** - Performance optimization strategies and techniques + +### Examples +- **practical-patterns.tsx** - Real-world React patterns and solutions + +## What This Skill Covers + +### Core Topics +- React 19 features and improvements +- All built-in hooks (useState, useEffect, useTransition, useOptimistic, etc.) +- Component patterns and composition +- Server Components and Server Functions +- React Compiler and automatic optimization +- Performance optimization techniques +- Form handling and validation +- Error boundaries and error handling +- Context and global state management +- Code splitting and lazy loading + +### Best Practices +- Component design principles +- State management strategies +- Performance optimization +- Error handling patterns +- TypeScript integration +- Testing considerations +- Accessibility guidelines + +## When to Use This Skill + +Use this skill when: +- Building React 19 applications +- Working with React hooks +- Implementing server components +- Optimizing React performance +- Troubleshooting React-specific issues +- Understanding concurrent features +- Working with forms and user input +- Implementing complex UI patterns + +## Quick Start Examples + +### Basic Component +```typescript +interface ButtonProps { + label: string + onClick: () => void +} + +const Button = ({ label, onClick }: ButtonProps) => { + return <button onClick={onClick}>{label}</button> +} +``` + +### Using Hooks +```typescript +const Counter = () => { + const [count, setCount] = useState(0) + + useEffect(() => { + console.log(`Count is: ${count}`) + }, [count]) + + return ( + <button onClick={() => setCount(c => c + 1)}> + Count: {count} + </button> + ) +} +``` + +### Server Component +```typescript +const Page = async () => { + const data = await fetchData() + return <div>{data}</div> +} +``` + +### Server Function +```typescript +'use server' + +export async function createUser(formData: FormData) { + const name = formData.get('name') + return await db.user.create({ data: { name } }) +} +``` + +## Related Skills + +- **typescript** - TypeScript patterns for React +- **ndk** - Nostr integration with React +- **skill-creator** - Creating reusable component libraries + +## Resources + +- [React Documentation](https://react.dev) +- [React API Reference](https://react.dev/reference/react) +- [React Hooks Reference](https://react.dev/reference/react/hooks) +- [React Server Components](https://react.dev/reference/rsc) +- [React Compiler](https://react.dev/reference/react-compiler) + +## Version + +This skill is based on React 19.2 and includes the latest features and APIs. + diff --git a/.claude/skills/react/SKILL.md b/.claude/skills/react/SKILL.md new file mode 100644 index 00000000..abe826fa --- /dev/null +++ b/.claude/skills/react/SKILL.md @@ -0,0 +1,1026 @@ +--- +name: react +description: This skill should be used when working with React 19, including hooks, components, server components, concurrent features, and React DOM APIs. Provides comprehensive knowledge of React patterns, best practices, and modern React architecture. +--- + +# React 19 Skill + +This skill provides comprehensive knowledge and patterns for working with React 19 effectively in modern applications. + +## When to Use This Skill + +Use this skill when: +- Building React applications with React 19 features +- Working with React hooks and component patterns +- Implementing server components and server functions +- Using concurrent features and transitions +- Optimizing React application performance +- Troubleshooting React-specific issues +- Working with React DOM APIs and client/server rendering +- Using React Compiler features + +## Core Concepts + +### React 19 Overview + +React 19 introduces significant improvements: +- **Server Components** - Components that render on the server +- **Server Functions** - Functions that run on the server from client code +- **Concurrent Features** - Better performance with concurrent rendering +- **React Compiler** - Automatic memoization and optimization +- **Form Actions** - Built-in form handling with useActionState +- **Improved Hooks** - New hooks like useOptimistic, useActionState +- **Better Hydration** - Improved SSR and hydration performance + +### Component Fundamentals + +Use functional components with hooks: + +```typescript +// Functional component with props interface +interface ButtonProps { + label: string + onClick: () => void + variant?: 'primary' | 'secondary' +} + +const Button = ({ label, onClick, variant = 'primary' }: ButtonProps) => { + return ( + <button + onClick={onClick} + className={`btn btn-${variant}`} + > + {label} + </button> + ) +} +``` + +**Key Principles:** +- Use functional components over class components +- Define prop interfaces in TypeScript +- Use destructuring for props +- Provide default values for optional props +- Keep components focused and composable + +## React Hooks Reference + +### State Hooks + +#### useState +Manage local component state: + +```typescript +const [count, setCount] = useState<number>(0) +const [user, setUser] = useState<User | null>(null) + +// Named return variables pattern +const handleIncrement = () => { + setCount(prev => prev + 1) // Functional update +} + +// Update object state immutably +setUser(prev => prev ? { ...prev, name: 'New Name' } : null) +``` + +#### useReducer +Manage complex state with reducer pattern: + +```typescript +type State = { count: number; status: 'idle' | 'loading' } +type Action = + | { type: 'increment' } + | { type: 'decrement' } + | { type: 'setStatus'; status: State['status'] } + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'increment': + return { ...state, count: state.count + 1 } + case 'decrement': + return { ...state, count: state.count - 1 } + case 'setStatus': + return { ...state, status: action.status } + default: + return state + } +} + +const [state, dispatch] = useReducer(reducer, { count: 0, status: 'idle' }) +``` + +#### useActionState +Handle form actions with pending states (React 19): + +```typescript +const [state, formAction, isPending] = useActionState( + async (previousState: FormState, formData: FormData) => { + const name = formData.get('name') as string + + // Server action or async operation + const result = await saveUser({ name }) + + return { success: true, data: result } + }, + { success: false, data: null } +) + +return ( + <form action={formAction}> + <input name="name" /> + <button disabled={isPending}> + {isPending ? 'Saving...' : 'Save'} + </button> + </form> +) +``` + +### Effect Hooks + +#### useEffect +Run side effects after render: + +```typescript +// Named return variables preferred +useEffect(() => { + const controller = new AbortController() + + const fetchData = async () => { + const response = await fetch('/api/data', { + signal: controller.signal + }) + const data = await response.json() + setData(data) + } + + fetchData() + + // Cleanup function + return () => { + controller.abort() + } +}, [dependencies]) // Dependencies array +``` + +**Key Points:** +- Always return cleanup function for subscriptions +- Use dependency array correctly to avoid infinite loops +- Don't forget to handle race conditions with AbortController +- Effects run after paint, not during render + +#### useLayoutEffect +Run effects synchronously after DOM mutations but before paint: + +```typescript +useLayoutEffect(() => { + // Measure DOM nodes + const height = ref.current?.getBoundingClientRect().height + setHeight(height) +}, []) +``` + +Use when you need to: +- Measure DOM layout +- Synchronously re-render before browser paints +- Prevent visual flicker + +#### useInsertionEffect +Insert styles before any DOM reads (for CSS-in-JS libraries): + +```typescript +useInsertionEffect(() => { + const style = document.createElement('style') + style.textContent = '.my-class { color: red; }' + document.head.appendChild(style) + + return () => { + document.head.removeChild(style) + } +}, []) +``` + +### Performance Hooks + +#### useMemo +Memoize expensive calculations: + +```typescript +const expensiveValue = useMemo(() => { + return computeExpensiveValue(a, b) +}, [a, b]) +``` + +**When to use:** +- Expensive calculations that would slow down renders +- Creating stable object references for dependency arrays +- Optimizing child component re-renders + +**When NOT to use:** +- Simple calculations (overhead not worth it) +- Values that change frequently + +#### useCallback +Memoize callback functions: + +```typescript +const handleClick = useCallback(() => { + console.log('Clicked', value) +}, [value]) + +// Pass to child that uses memo +<ChildComponent onClick={handleClick} /> +``` + +**Use when:** +- Passing callbacks to optimized child components +- Function is a dependency in another hook +- Function is used in effect cleanup + +### Ref Hooks + +#### useRef +Store mutable values that don't trigger re-renders: + +```typescript +// DOM reference +const inputRef = useRef<HTMLInputElement>(null) + +useEffect(() => { + inputRef.current?.focus() +}, []) + +// Mutable value storage +const countRef = useRef<number>(0) +countRef.current += 1 // Doesn't trigger re-render +``` + +#### useImperativeHandle +Customize ref handle for parent components: + +```typescript +interface InputHandle { + focus: () => void + clear: () => void +} + +const CustomInput = forwardRef<InputHandle, InputProps>((props, ref) => { + const inputRef = useRef<HTMLInputElement>(null) + + useImperativeHandle(ref, () => ({ + focus: () => { + inputRef.current?.focus() + }, + clear: () => { + if (inputRef.current) { + inputRef.current.value = '' + } + } + })) + + return <input ref={inputRef} {...props} /> +}) +``` + +### Context Hooks + +#### useContext +Access context values: + +```typescript +// Create context +interface ThemeContext { + theme: 'light' | 'dark' + toggleTheme: () => void +} + +const ThemeContext = createContext<ThemeContext | null>(null) + +// Provider +const ThemeProvider = ({ children }: { children: React.ReactNode }) => { + const [theme, setTheme] = useState<'light' | 'dark'>('light') + + const toggleTheme = useCallback(() => { + setTheme(prev => prev === 'light' ? 'dark' : 'light') + }, []) + + return ( + <ThemeContext.Provider value={{ theme, toggleTheme }}> + {children} + </ThemeContext.Provider> + ) +} + +// Consumer +const ThemedButton = () => { + const context = useContext(ThemeContext) + if (!context) throw new Error('useTheme must be used within ThemeProvider') + + const { theme, toggleTheme } = context + + return ( + <button onClick={toggleTheme}> + Current theme: {theme} + </button> + ) +} +``` + +### Transition Hooks + +#### useTransition +Mark state updates as non-urgent: + +```typescript +const [isPending, startTransition] = useTransition() + +const handleTabChange = (newTab: string) => { + startTransition(() => { + setTab(newTab) // Non-urgent update + }) +} + +return ( + <> + <button onClick={() => handleTabChange('profile')}> + Profile + </button> + {isPending && <Spinner />} + <TabContent tab={tab} /> + </> +) +``` + +**Use for:** +- Marking expensive updates as non-urgent +- Keeping UI responsive during state transitions +- Preventing loading states for quick updates + +#### useDeferredValue +Defer re-rendering for non-urgent updates: + +```typescript +const [query, setQuery] = useState('') +const deferredQuery = useDeferredValue(query) + +// Use deferred value for expensive rendering +const results = useMemo(() => { + return searchResults(deferredQuery) +}, [deferredQuery]) + +return ( + <> + <input value={query} onChange={e => setQuery(e.target.value)} /> + <Results data={results} /> + </> +) +``` + +### Optimistic Updates + +#### useOptimistic +Show optimistic state while async operation completes (React 19): + +```typescript +const [optimisticMessages, addOptimisticMessage] = useOptimistic( + messages, + (state, newMessage: string) => [ + ...state, + { id: 'temp', text: newMessage, pending: true } + ] +) + +const handleSend = async (formData: FormData) => { + const message = formData.get('message') as string + + // Show optimistic update immediately + addOptimisticMessage(message) + + // Send to server + await sendMessage(message) +} + +return ( + <> + {optimisticMessages.map(msg => ( + <div key={msg.id} className={msg.pending ? 'opacity-50' : ''}> + {msg.text} + </div> + ))} + <form action={handleSend}> + <input name="message" /> + <button>Send</button> + </form> + </> +) +``` + +### Other Hooks + +#### useId +Generate unique IDs for accessibility: + +```typescript +const id = useId() + +return ( + <> + <label htmlFor={id}>Name:</label> + <input id={id} type="text" /> + </> +) +``` + +#### useSyncExternalStore +Subscribe to external stores: + +```typescript +const subscribe = (callback: () => void) => { + store.subscribe(callback) + return () => store.unsubscribe(callback) +} + +const getSnapshot = () => store.getState() +const getServerSnapshot = () => store.getInitialState() + +const state = useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot +) +``` + +#### useDebugValue +Display custom label in React DevTools: + +```typescript +const useCustomHook = (value: string) => { + useDebugValue(value ? `Active: ${value}` : 'Inactive') + return value +} +``` + +## React Components + +### Fragment +Group elements without extra DOM nodes: + +```typescript +// Short syntax +<> + <ChildA /> + <ChildB /> +</> + +// Full syntax (when you need key prop) +<Fragment key={item.id}> + <dt>{item.term}</dt> + <dd>{item.description}</dd> +</Fragment> +``` + +### Suspense +Show fallback while loading: + +```typescript +<Suspense fallback={<Loading />}> + <AsyncComponent /> +</Suspense> + +// With error boundary +<ErrorBoundary fallback={<Error />}> + <Suspense fallback={<Loading />}> + <AsyncComponent /> + </Suspense> +</ErrorBoundary> +``` + +### StrictMode +Enable additional checks in development: + +```typescript +<StrictMode> + <App /> +</StrictMode> +``` + +**StrictMode checks:** +- Warns about deprecated APIs +- Detects unexpected side effects +- Highlights potential problems +- Double-invokes functions to catch bugs + +### Profiler +Measure rendering performance: + +```typescript +<Profiler id="App" onRender={onRender}> + <App /> +</Profiler> + +const onRender = ( + id: string, + phase: 'mount' | 'update', + actualDuration: number, + baseDuration: number, + startTime: number, + commitTime: number +) => { + console.log(`${id} took ${actualDuration}ms`) +} +``` + +## React APIs + +### memo +Prevent unnecessary re-renders: + +```typescript +const ExpensiveComponent = memo(({ data }: Props) => { + return <div>{data}</div> +}, (prevProps, nextProps) => { + // Return true if props are equal (skip render) + return prevProps.data === nextProps.data +}) +``` + +### lazy +Code-split components: + +```typescript +const Dashboard = lazy(() => import('./Dashboard')) + +<Suspense fallback={<Loading />}> + <Dashboard /> +</Suspense> +``` + +### startTransition +Mark updates as transitions imperatively: + +```typescript +startTransition(() => { + setTab('profile') +}) +``` + +### cache (React Server Components) +Cache function results per request: + +```typescript +const getUser = cache(async (id: string) => { + return await db.user.findUnique({ where: { id } }) +}) +``` + +### use (React 19) +Read context or promises in render: + +```typescript +// Read context +const theme = use(ThemeContext) + +// Read promise (must be wrapped in Suspense) +const data = use(fetchDataPromise) +``` + +## Server Components & Server Functions + +### Server Components + +Components that run only on the server: + +```typescript +// app/page.tsx (Server Component by default) +const Page = async () => { + // Can fetch data directly + const posts = await db.post.findMany() + + return ( + <div> + {posts.map(post => ( + <PostCard key={post.id} post={post} /> + ))} + </div> + ) +} + +export default Page +``` + +**Benefits:** +- Direct database access +- Zero bundle size for server-only code +- Automatic code splitting +- Better performance + +### Server Functions + +Functions that run on server, callable from client: + +```typescript +'use server' + +export async function createPost(formData: FormData) { + const title = formData.get('title') as string + const content = formData.get('content') as string + + const post = await db.post.create({ + data: { title, content } + }) + + revalidatePath('/posts') + return post +} +``` + +**Usage from client:** + +```typescript +'use client' + +import { createPost } from './actions' + +const PostForm = () => { + const [state, formAction] = useActionState(createPost, null) + + return ( + <form action={formAction}> + <input name="title" /> + <textarea name="content" /> + <button>Create</button> + </form> + ) +} +``` + +### Directives + +#### 'use client' +Mark file as client component: + +```typescript +'use client' + +import { useState } from 'react' + +// This component runs on client +export const Counter = () => { + const [count, setCount] = useState(0) + return <button onClick={() => setCount(c => c + 1)}>{count}</button> +} +``` + +#### 'use server' +Mark functions as server functions: + +```typescript +'use server' + +export async function updateUser(userId: string, data: UserData) { + return await db.user.update({ where: { id: userId }, data }) +} +``` + +## React DOM + +### Client APIs + +#### createRoot +Create root for client rendering (React 19): + +```typescript +import { createRoot } from 'react-dom/client' + +const root = createRoot(document.getElementById('root')!) +root.render(<App />) + +// Update root +root.render(<App newProp="value" />) + +// Unmount +root.unmount() +``` + +#### hydrateRoot +Hydrate server-rendered HTML: + +```typescript +import { hydrateRoot } from 'react-dom/client' + +hydrateRoot(document.getElementById('root')!, <App />) +``` + +### Component APIs + +#### createPortal +Render children outside parent DOM hierarchy: + +```typescript +import { createPortal } from 'react-dom' + +const Modal = ({ children }: { children: React.ReactNode }) => { + return createPortal( + <div className="modal">{children}</div>, + document.body + ) +} +``` + +#### flushSync +Force synchronous update: + +```typescript +import { flushSync } from 'react-dom' + +flushSync(() => { + setCount(1) +}) +// DOM is updated synchronously +``` + +### Form Components + +#### <form> with actions + +```typescript +const handleSubmit = async (formData: FormData) => { + 'use server' + const email = formData.get('email') + await saveEmail(email) +} + +<form action={handleSubmit}> + <input name="email" type="email" /> + <button>Subscribe</button> +</form> +``` + +#### useFormStatus + +```typescript +import { useFormStatus } from 'react-dom' + +const SubmitButton = () => { + const { pending } = useFormStatus() + + return ( + <button disabled={pending}> + {pending ? 'Submitting...' : 'Submit'} + </button> + ) +} +``` + +## React Compiler + +### Configuration + +Configure React Compiler in babel or bundler config: + +```javascript +// babel.config.js +module.exports = { + plugins: [ + ['react-compiler', { + compilationMode: 'annotation', // or 'all' + panicThreshold: 'all_errors', + }] + ] +} +``` + +### Directives + +#### "use memo" +Force memoization of component: + +```typescript +'use memo' + +const ExpensiveComponent = ({ data }: Props) => { + const processed = expensiveComputation(data) + return <div>{processed}</div> +} +``` + +#### "use no memo" +Prevent automatic memoization: + +```typescript +'use no memo' + +const SimpleComponent = ({ text }: Props) => { + return <div>{text}</div> +} +``` + +## Best Practices + +### Component Design + +1. **Keep components focused** - Single responsibility principle +2. **Prefer composition** - Build complex UIs from simple components +3. **Extract custom hooks** - Reusable logic in hooks +4. **Named return variables** - Use named returns in functions +5. **Type everything** - Proper TypeScript interfaces for all props + +### Performance + +1. **Use React.memo sparingly** - Only for expensive components +2. **Optimize context** - Split contexts to avoid unnecessary re-renders +3. **Lazy load routes** - Code-split at route boundaries +4. **Use transitions** - Mark non-urgent updates with useTransition +5. **Virtualize lists** - Use libraries like react-window for long lists + +### State Management + +1. **Local state first** - useState for component-specific state +2. **Lift state up** - Only when multiple components need it +3. **Use reducers for complex state** - useReducer for complex logic +4. **Context for global state** - Theme, auth, etc. +5. **External stores** - TanStack Query, Zustand for complex apps + +### Error Handling + +1. **Error boundaries** - Catch rendering errors +2. **Guard clauses** - Early returns for invalid states +3. **Null checks** - Always check for null/undefined +4. **Try-catch in effects** - Handle async errors +5. **User-friendly errors** - Show helpful error messages + +### Testing Considerations + +1. **Testable components** - Pure, predictable components +2. **Test user behavior** - Not implementation details +3. **Mock external dependencies** - APIs, context, etc. +4. **Test error states** - Verify error handling works +5. **Accessibility tests** - Test keyboard navigation, screen readers + +## Common Patterns + +### Compound Components + +```typescript +interface TabsProps { + children: React.ReactNode + defaultValue: string +} + +const TabsContext = createContext<{ + value: string + setValue: (v: string) => void +} | null>(null) + +const Tabs = ({ children, defaultValue }: TabsProps) => { + const [value, setValue] = useState(defaultValue) + + return ( + <TabsContext.Provider value={{ value, setValue }}> + {children} + </TabsContext.Provider> + ) +} + +const TabsList = ({ children }: { children: React.ReactNode }) => ( + <div role="tablist">{children}</div> +) + +const TabsTrigger = ({ value, children }: { value: string, children: React.ReactNode }) => { + const context = useContext(TabsContext) + if (!context) throw new Error('TabsTrigger must be used within Tabs') + + return ( + <button + role="tab" + aria-selected={context.value === value} + onClick={() => context.setValue(value)} + > + {children} + </button> + ) +} + +const TabsContent = ({ value, children }: { value: string, children: React.ReactNode }) => { + const context = useContext(TabsContext) + if (!context) throw new Error('TabsContent must be used within Tabs') + + if (context.value !== value) return null + + return <div role="tabpanel">{children}</div> +} + +// Usage +<Tabs defaultValue="profile"> + <TabsList> + <TabsTrigger value="profile">Profile</TabsTrigger> + <TabsTrigger value="settings">Settings</TabsTrigger> + </TabsList> + <TabsContent value="profile">Profile content</TabsContent> + <TabsContent value="settings">Settings content</TabsContent> +</Tabs> +``` + +### Render Props + +```typescript +interface DataFetcherProps<T> { + url: string + children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode +} + +const DataFetcher = <T,>({ url, children }: DataFetcherProps<T>) => { + const [data, setData] = useState<T | null>(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState<Error | null>(null) + + useEffect(() => { + fetch(url) + .then(res => res.json()) + .then(setData) + .catch(setError) + .finally(() => setLoading(false)) + }, [url]) + + return <>{children(data, loading, error)}</> +} + +// Usage +<DataFetcher<User> url="/api/user"> + {(user, loading, error) => { + if (loading) return <Spinner /> + if (error) return <Error error={error} /> + if (!user) return null + return <UserProfile user={user} /> + }} +</DataFetcher> +``` + +### Custom Hooks Pattern + +```typescript +const useLocalStorage = <T,>(key: string, initialValue: T) => { + const [storedValue, setStoredValue] = useState<T>(() => { + try { + const item = window.localStorage.getItem(key) + return item ? JSON.parse(item) : initialValue + } catch (error) { + console.error(error) + return initialValue + } + }) + + const setValue = useCallback((value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value + setStoredValue(valueToStore) + window.localStorage.setItem(key, JSON.stringify(valueToStore)) + } catch (error) { + console.error(error) + } + }, [key, storedValue]) + + return [storedValue, setValue] as const +} +``` + +## Troubleshooting + +### Common Issues + +#### Infinite Loops +- Check useEffect dependencies +- Ensure state updates don't trigger themselves +- Use functional setState updates + +#### Stale Closures +- Add all used variables to dependency arrays +- Use useCallback for functions in dependencies +- Consider using refs for values that shouldn't trigger re-renders + +#### Performance Issues +- Use React DevTools Profiler +- Check for unnecessary re-renders +- Optimize with memo, useMemo, useCallback +- Consider code splitting + +#### Hydration Mismatches +- Ensure server and client render same HTML +- Avoid using Date.now() or random values during render +- Use useEffect for browser-only code +- Check for conditional rendering based on browser APIs + +## References + +- **React Documentation**: https://react.dev +- **React API Reference**: https://react.dev/reference/react +- **React DOM Reference**: https://react.dev/reference/react-dom +- **React Compiler**: https://react.dev/reference/react-compiler +- **Rules of React**: https://react.dev/reference/rules +- **GitHub**: https://github.com/facebook/react + +## Related Skills + +- **typescript** - TypeScript patterns and types for React +- **ndk** - Nostr integration with React hooks +- **skill-creator** - Creating reusable component libraries + diff --git a/.claude/skills/react/examples/practical-patterns.tsx b/.claude/skills/react/examples/practical-patterns.tsx new file mode 100644 index 00000000..2883726f --- /dev/null +++ b/.claude/skills/react/examples/practical-patterns.tsx @@ -0,0 +1,878 @@ +# React Practical Examples + +This file contains real-world examples of React patterns and solutions. + +## Example 1: Custom Hook for Data Fetching + +```typescript +import { useState, useEffect } from 'react' + +interface FetchState<T> { + data: T | null + loading: boolean + error: Error | null +} + +const useFetch = <T,>(url: string) => { + const [state, setState] = useState<FetchState<T>>({ + data: null, + loading: true, + error: null + }) + + useEffect(() => { + let cancelled = false + const controller = new AbortController() + + const fetchData = async () => { + try { + setState(prev => ({ ...prev, loading: true, error: null })) + + const response = await fetch(url, { + signal: controller.signal + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + + if (!cancelled) { + setState({ data, loading: false, error: null }) + } + } catch (error) { + if (!cancelled && error.name !== 'AbortError') { + setState({ + data: null, + loading: false, + error: error as Error + }) + } + } + } + + fetchData() + + return () => { + cancelled = true + controller.abort() + } + }, [url]) + + return state +} + +// Usage +const UserProfile = ({ userId }: { userId: string }) => { + const { data, loading, error } = useFetch<User>(`/api/users/${userId}`) + + if (loading) return <Spinner /> + if (error) return <ErrorMessage error={error} /> + if (!data) return null + + return <UserCard user={data} /> +} +``` + +## Example 2: Form with Validation + +```typescript +import { useState, useCallback } from 'react' +import { z } from 'zod' + +const userSchema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + email: z.string().email('Invalid email address'), + age: z.number().min(18, 'Must be 18 or older') +}) + +type UserForm = z.infer<typeof userSchema> +type FormErrors = Partial<Record<keyof UserForm, string>> + +const UserForm = () => { + const [formData, setFormData] = useState<UserForm>({ + name: '', + email: '', + age: 0 + }) + const [errors, setErrors] = useState<FormErrors>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleChange = useCallback(( + field: keyof UserForm, + value: string | number + ) => { + setFormData(prev => ({ ...prev, [field]: value })) + // Clear error when user starts typing + setErrors(prev => ({ ...prev, [field]: undefined })) + }, []) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + // Validate + const result = userSchema.safeParse(formData) + if (!result.success) { + const fieldErrors: FormErrors = {} + result.error.errors.forEach(err => { + const field = err.path[0] as keyof UserForm + fieldErrors[field] = err.message + }) + setErrors(fieldErrors) + return + } + + // Submit + setIsSubmitting(true) + try { + await submitUser(result.data) + // Success handling + } catch (error) { + console.error(error) + } finally { + setIsSubmitting(false) + } + } + + return ( + <form onSubmit={handleSubmit}> + <div> + <label htmlFor="name">Name</label> + <input + id="name" + value={formData.name} + onChange={e => handleChange('name', e.target.value)} + /> + {errors.name && <span className="error">{errors.name}</span>} + </div> + + <div> + <label htmlFor="email">Email</label> + <input + id="email" + type="email" + value={formData.email} + onChange={e => handleChange('email', e.target.value)} + /> + {errors.email && <span className="error">{errors.email}</span>} + </div> + + <div> + <label htmlFor="age">Age</label> + <input + id="age" + type="number" + value={formData.age || ''} + onChange={e => handleChange('age', Number(e.target.value))} + /> + {errors.age && <span className="error">{errors.age}</span>} + </div> + + <button type="submit" disabled={isSubmitting}> + {isSubmitting ? 'Submitting...' : 'Submit'} + </button> + </form> + ) +} +``` + +## Example 3: Modal with Portal + +```typescript +import { createPortal } from 'react-dom' +import { useEffect, useRef, useState } from 'react' + +interface ModalProps { + isOpen: boolean + onClose: () => void + children: React.ReactNode + title?: string +} + +const Modal = ({ isOpen, onClose, children, title }: ModalProps) => { + const modalRef = useRef<HTMLDivElement>(null) + + // Close on Escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + + if (isOpen) { + document.addEventListener('keydown', handleEscape) + // Prevent body scroll + document.body.style.overflow = 'hidden' + } + + return () => { + document.removeEventListener('keydown', handleEscape) + document.body.style.overflow = 'unset' + } + }, [isOpen, onClose]) + + // Close on backdrop click + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === modalRef.current) { + onClose() + } + } + + if (!isOpen) return null + + return createPortal( + <div + ref={modalRef} + className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" + onClick={handleBackdropClick} + > + <div className="bg-white rounded-lg p-6 max-w-md w-full mx-4"> + <div className="flex justify-between items-center mb-4"> + {title && <h2 className="text-xl font-bold">{title}</h2>} + <button + onClick={onClose} + className="text-gray-500 hover:text-gray-700" + aria-label="Close modal" + > + ✕ + </button> + </div> + {children} + </div> + </div>, + document.body + ) +} + +// Usage +const App = () => { + const [isOpen, setIsOpen] = useState(false) + + return ( + <> + <button onClick={() => setIsOpen(true)}>Open Modal</button> + <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="My Modal"> + <p>Modal content goes here</p> + <button onClick={() => setIsOpen(false)}>Close</button> + </Modal> + </> + ) +} +``` + +## Example 4: Infinite Scroll + +```typescript +import { useState, useEffect, useRef, useCallback } from 'react' + +interface InfiniteScrollProps<T> { + fetchData: (page: number) => Promise<T[]> + renderItem: (item: T, index: number) => React.ReactNode + loader?: React.ReactNode + endMessage?: React.ReactNode +} + +const InfiniteScroll = <T extends { id: string | number },>({ + fetchData, + renderItem, + loader = <div>Loading...</div>, + endMessage = <div>No more items</div> +}: InfiniteScrollProps<T>) => { + const [items, setItems] = useState<T[]>([]) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(false) + const [hasMore, setHasMore] = useState(true) + const observerRef = useRef<IntersectionObserver | null>(null) + const loadMoreRef = useRef<HTMLDivElement>(null) + + const loadMore = useCallback(async () => { + if (loading || !hasMore) return + + setLoading(true) + try { + const newItems = await fetchData(page) + + if (newItems.length === 0) { + setHasMore(false) + } else { + setItems(prev => [...prev, ...newItems]) + setPage(prev => prev + 1) + } + } catch (error) { + console.error('Failed to load items:', error) + } finally { + setLoading(false) + } + }, [page, loading, hasMore, fetchData]) + + // Set up intersection observer + useEffect(() => { + observerRef.current = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) { + loadMore() + } + }, + { threshold: 0.1 } + ) + + const currentRef = loadMoreRef.current + if (currentRef) { + observerRef.current.observe(currentRef) + } + + return () => { + if (observerRef.current && currentRef) { + observerRef.current.unobserve(currentRef) + } + } + }, [loadMore]) + + // Initial load + useEffect(() => { + loadMore() + }, []) + + return ( + <div> + {items.map((item, index) => ( + <div key={item.id}> + {renderItem(item, index)} + </div> + ))} + + <div ref={loadMoreRef}> + {loading && loader} + {!loading && !hasMore && endMessage} + </div> + </div> + ) +} + +// Usage +const PostsList = () => { + const fetchPosts = async (page: number) => { + const response = await fetch(`/api/posts?page=${page}`) + return response.json() + } + + return ( + <InfiniteScroll<Post> + fetchData={fetchPosts} + renderItem={(post) => <PostCard post={post} />} + /> + ) +} +``` + +## Example 5: Dark Mode Toggle + +```typescript +import { createContext, useContext, useState, useEffect } from 'react' + +type Theme = 'light' | 'dark' + +interface ThemeContextType { + theme: Theme + toggleTheme: () => void +} + +const ThemeContext = createContext<ThemeContextType | null>(null) + +export const useTheme = () => { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within ThemeProvider') + } + return context +} + +export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { + const [theme, setTheme] = useState<Theme>(() => { + // Check localStorage and system preference + const saved = localStorage.getItem('theme') as Theme | null + if (saved) return saved + + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + + return 'light' + }) + + useEffect(() => { + // Update DOM and localStorage + const root = document.documentElement + root.classList.remove('light', 'dark') + root.classList.add(theme) + localStorage.setItem('theme', theme) + }, [theme]) + + const toggleTheme = () => { + setTheme(prev => prev === 'light' ? 'dark' : 'light') + } + + return ( + <ThemeContext.Provider value={{ theme, toggleTheme }}> + {children} + </ThemeContext.Provider> + ) +} + +// Usage +const ThemeToggle = () => { + const { theme, toggleTheme } = useTheme() + + return ( + <button onClick={toggleTheme} aria-label="Toggle theme"> + {theme === 'light' ? '🌙' : '☀️'} + </button> + ) +} +``` + +## Example 6: Debounced Search + +```typescript +import { useState, useEffect, useMemo } from 'react' + +const useDebounce = <T,>(value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} + +const SearchPage = () => { + const [query, setQuery] = useState('') + const [results, setResults] = useState<Product[]>([]) + const [loading, setLoading] = useState(false) + + const debouncedQuery = useDebounce(query, 500) + + useEffect(() => { + if (!debouncedQuery) { + setResults([]) + return + } + + const searchProducts = async () => { + setLoading(true) + try { + const response = await fetch(`/api/search?q=${debouncedQuery}`) + const data = await response.json() + setResults(data) + } catch (error) { + console.error('Search failed:', error) + } finally { + setLoading(false) + } + } + + searchProducts() + }, [debouncedQuery]) + + return ( + <div> + <input + type="search" + value={query} + onChange={e => setQuery(e.target.value)} + placeholder="Search products..." + /> + + {loading && <Spinner />} + + {!loading && results.length > 0 && ( + <div> + {results.map(product => ( + <ProductCard key={product.id} product={product} /> + ))} + </div> + )} + + {!loading && query && results.length === 0 && ( + <p>No results found for "{query}"</p> + )} + </div> + ) +} +``` + +## Example 7: Tabs Component + +```typescript +import { createContext, useContext, useState, useId } from 'react' + +interface TabsContextType { + activeTab: string + setActiveTab: (id: string) => void + tabsId: string +} + +const TabsContext = createContext<TabsContextType | null>(null) + +const useTabs = () => { + const context = useContext(TabsContext) + if (!context) throw new Error('Tabs compound components must be used within Tabs') + return context +} + +interface TabsProps { + children: React.ReactNode + defaultValue: string + className?: string +} + +const Tabs = ({ children, defaultValue, className }: TabsProps) => { + const [activeTab, setActiveTab] = useState(defaultValue) + const tabsId = useId() + + return ( + <TabsContext.Provider value={{ activeTab, setActiveTab, tabsId }}> + <div className={className}> + {children} + </div> + </TabsContext.Provider> + ) +} + +const TabsList = ({ children, className }: { + children: React.ReactNode + className?: string +}) => ( + <div role="tablist" className={className}> + {children} + </div> +) + +interface TabsTriggerProps { + value: string + children: React.ReactNode + className?: string +} + +const TabsTrigger = ({ value, children, className }: TabsTriggerProps) => { + const { activeTab, setActiveTab, tabsId } = useTabs() + const isActive = activeTab === value + + return ( + <button + role="tab" + id={`${tabsId}-tab-${value}`} + aria-controls={`${tabsId}-panel-${value}`} + aria-selected={isActive} + onClick={() => setActiveTab(value)} + className={`${className} ${isActive ? 'active' : ''}`} + > + {children} + </button> + ) +} + +interface TabsContentProps { + value: string + children: React.ReactNode + className?: string +} + +const TabsContent = ({ value, children, className }: TabsContentProps) => { + const { activeTab, tabsId } = useTabs() + + if (activeTab !== value) return null + + return ( + <div + role="tabpanel" + id={`${tabsId}-panel-${value}`} + aria-labelledby={`${tabsId}-tab-${value}`} + className={className} + > + {children} + </div> + ) +} + +// Export compound component +export { Tabs, TabsList, TabsTrigger, TabsContent } + +// Usage +const App = () => ( + <Tabs defaultValue="profile"> + <TabsList> + <TabsTrigger value="profile">Profile</TabsTrigger> + <TabsTrigger value="settings">Settings</TabsTrigger> + <TabsTrigger value="notifications">Notifications</TabsTrigger> + </TabsList> + + <TabsContent value="profile"> + <h2>Profile Content</h2> + </TabsContent> + + <TabsContent value="settings"> + <h2>Settings Content</h2> + </TabsContent> + + <TabsContent value="notifications"> + <h2>Notifications Content</h2> + </TabsContent> + </Tabs> +) +``` + +## Example 8: Error Boundary + +```typescript +import { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: (error: Error, reset: () => void) => ReactNode + onError?: (error: Error, errorInfo: ErrorInfo) => void +} + +interface State { + hasError: boolean + error: Error | null +} + +class ErrorBoundary extends Component<Props, State> { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('ErrorBoundary caught:', error, errorInfo) + this.props.onError?.(error, errorInfo) + } + + reset = () => { + this.setState({ hasError: false, error: null }) + } + + render() { + if (this.state.hasError && this.state.error) { + if (this.props.fallback) { + return this.props.fallback(this.state.error, this.reset) + } + + return ( + <div className="error-boundary"> + <h2>Something went wrong</h2> + <details> + <summary>Error details</summary> + <pre>{this.state.error.message}</pre> + </details> + <button onClick={this.reset}>Try again</button> + </div> + ) + } + + return this.props.children + } +} + +// Usage +const App = () => ( + <ErrorBoundary + fallback={(error, reset) => ( + <div> + <h1>Oops! Something went wrong</h1> + <p>{error.message}</p> + <button onClick={reset}>Retry</button> + </div> + )} + onError={(error, errorInfo) => { + // Send to error tracking service + console.error('Error logged:', error, errorInfo) + }} + > + <YourApp /> + </ErrorBoundary> +) +``` + +## Example 9: Custom Hook for Local Storage + +```typescript +import { useState, useEffect, useCallback } from 'react' + +const useLocalStorage = <T,>( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void, () => void] => { + // Get initial value from localStorage + const [storedValue, setStoredValue] = useState<T>(() => { + try { + const item = window.localStorage.getItem(key) + return item ? JSON.parse(item) : initialValue + } catch (error) { + console.error(`Error loading ${key} from localStorage:`, error) + return initialValue + } + }) + + // Update localStorage when value changes + const setValue = useCallback((value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value + setStoredValue(valueToStore) + window.localStorage.setItem(key, JSON.stringify(valueToStore)) + + // Dispatch storage event for other tabs + window.dispatchEvent(new Event('storage')) + } catch (error) { + console.error(`Error saving ${key} to localStorage:`, error) + } + }, [key, storedValue]) + + // Remove from localStorage + const removeValue = useCallback(() => { + try { + window.localStorage.removeItem(key) + setStoredValue(initialValue) + } catch (error) { + console.error(`Error removing ${key} from localStorage:`, error) + } + }, [key, initialValue]) + + // Listen for changes in other tabs + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key && e.newValue) { + setStoredValue(JSON.parse(e.newValue)) + } + } + + window.addEventListener('storage', handleStorageChange) + return () => window.removeEventListener('storage', handleStorageChange) + }, [key]) + + return [storedValue, setValue, removeValue] +} + +// Usage +const UserPreferences = () => { + const [preferences, setPreferences, clearPreferences] = useLocalStorage('user-prefs', { + theme: 'light', + language: 'en', + notifications: true + }) + + return ( + <div> + <label> + <input + type="checkbox" + checked={preferences.notifications} + onChange={e => setPreferences({ + ...preferences, + notifications: e.target.checked + })} + /> + Enable notifications + </label> + + <button onClick={clearPreferences}> + Reset to defaults + </button> + </div> + ) +} +``` + +## Example 10: Optimistic Updates with useOptimistic + +```typescript +'use client' + +import { useOptimistic } from 'react' +import { likePost, unlikePost } from './actions' + +interface Post { + id: string + content: string + likes: number + isLiked: boolean +} + +const PostCard = ({ post }: { post: Post }) => { + const [optimisticPost, addOptimistic] = useOptimistic( + post, + (currentPost, update: Partial<Post>) => ({ + ...currentPost, + ...update + }) + ) + + const handleLike = async () => { + // Optimistically update UI + addOptimistic({ + likes: optimisticPost.likes + 1, + isLiked: true + }) + + try { + // Send server request + await likePost(post.id) + } catch (error) { + // Server will send correct state via revalidation + console.error('Failed to like post:', error) + } + } + + const handleUnlike = async () => { + addOptimistic({ + likes: optimisticPost.likes - 1, + isLiked: false + }) + + try { + await unlikePost(post.id) + } catch (error) { + console.error('Failed to unlike post:', error) + } + } + + return ( + <div className="post-card"> + <p>{optimisticPost.content}</p> + <button + onClick={optimisticPost.isLiked ? handleUnlike : handleLike} + className={optimisticPost.isLiked ? 'liked' : ''} + > + ❤️ {optimisticPost.likes} + </button> + </div> + ) +} +``` + +## References + +These examples demonstrate: +- Custom hooks for reusable logic +- Form handling with validation +- Portal usage for modals +- Infinite scroll with Intersection Observer +- Context for global state +- Debouncing for performance +- Compound components pattern +- Error boundaries +- LocalStorage integration +- Optimistic updates (React 19) + diff --git a/.claude/skills/react/references/hooks-quick-reference.md b/.claude/skills/react/references/hooks-quick-reference.md new file mode 100644 index 00000000..26e8f595 --- /dev/null +++ b/.claude/skills/react/references/hooks-quick-reference.md @@ -0,0 +1,291 @@ +# React Hooks Quick Reference + +## State Hooks + +### useState +```typescript +const [state, setState] = useState<Type>(initialValue) +const [count, setCount] = useState(0) + +// Functional update +setCount(prev => prev + 1) + +// Lazy initialization +const [state, setState] = useState(() => expensiveComputation()) +``` + +### useReducer +```typescript +type State = { count: number } +type Action = { type: 'increment' } | { type: 'decrement' } + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'increment': return { count: state.count + 1 } + case 'decrement': return { count: state.count - 1 } + } +} + +const [state, dispatch] = useReducer(reducer, { count: 0 }) +dispatch({ type: 'increment' }) +``` + +### useActionState (React 19) +```typescript +const [state, formAction, isPending] = useActionState( + async (previousState, formData: FormData) => { + // Server action + return await processForm(formData) + }, + initialState +) + +<form action={formAction}> + <button disabled={isPending}>Submit</button> +</form> +``` + +## Effect Hooks + +### useEffect +```typescript +useEffect(() => { + // Side effect + const subscription = api.subscribe() + + // Cleanup + return () => subscription.unsubscribe() +}, [dependencies]) +``` + +**Timing**: After render & paint +**Use for**: Data fetching, subscriptions, DOM mutations + +### useLayoutEffect +```typescript +useLayoutEffect(() => { + // Runs before paint + const height = ref.current.offsetHeight + setHeight(height) +}, []) +``` + +**Timing**: After render, before paint +**Use for**: DOM measurements, preventing flicker + +### useInsertionEffect +```typescript +useInsertionEffect(() => { + // Insert styles before any DOM reads + const style = document.createElement('style') + style.textContent = css + document.head.appendChild(style) + return () => document.head.removeChild(style) +}, [css]) +``` + +**Timing**: Before any DOM mutations +**Use for**: CSS-in-JS libraries + +## Performance Hooks + +### useMemo +```typescript +const memoizedValue = useMemo(() => { + return expensiveComputation(a, b) +}, [a, b]) +``` + +**Use for**: Expensive calculations, stable object references + +### useCallback +```typescript +const memoizedCallback = useCallback(() => { + doSomething(a, b) +}, [a, b]) +``` + +**Use for**: Passing callbacks to optimized components + +## Ref Hooks + +### useRef +```typescript +// DOM reference +const ref = useRef<HTMLDivElement>(null) +ref.current?.focus() + +// Mutable value (doesn't trigger re-render) +const countRef = useRef(0) +countRef.current += 1 +``` + +### useImperativeHandle +```typescript +useImperativeHandle(ref, () => ({ + focus: () => inputRef.current?.focus(), + clear: () => inputRef.current && (inputRef.current.value = '') +}), []) +``` + +## Context Hook + +### useContext +```typescript +const value = useContext(MyContext) +``` + +Must be used within a Provider. + +## Transition Hooks + +### useTransition +```typescript +const [isPending, startTransition] = useTransition() + +startTransition(() => { + setState(newValue) // Non-urgent update +}) +``` + +### useDeferredValue +```typescript +const [input, setInput] = useState('') +const deferredInput = useDeferredValue(input) + +// Use deferredInput for expensive operations +const results = useMemo(() => search(deferredInput), [deferredInput]) +``` + +## Optimistic Updates (React 19) + +### useOptimistic +```typescript +const [optimisticState, addOptimistic] = useOptimistic( + actualState, + (currentState, optimisticValue) => { + return [...currentState, optimisticValue] + } +) +``` + +## Other Hooks + +### useId +```typescript +const id = useId() +<label htmlFor={id}>Name</label> +<input id={id} /> +``` + +### useSyncExternalStore +```typescript +const state = useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot +) +``` + +### useDebugValue +```typescript +useDebugValue(isOnline ? 'Online' : 'Offline') +``` + +### use (React 19) +```typescript +// Read context or promise +const value = use(MyContext) +const data = use(fetchPromise) // Must be in Suspense +``` + +## Form Hooks (React DOM) + +### useFormStatus +```typescript +import { useFormStatus } from 'react-dom' + +const { pending, data, method, action } = useFormStatus() +``` + +## Hook Rules + +1. **Only call at top level** - Not in loops, conditions, or nested functions +2. **Only call from React functions** - Components or custom hooks +3. **Custom hooks start with "use"** - Naming convention +4. **Same hooks in same order** - Every render must call same hooks + +## Dependencies Best Practices + +1. **Include all used values** - Variables, props, state from component scope +2. **Use ESLint plugin** - `eslint-plugin-react-hooks` enforces rules +3. **Functions as dependencies** - Wrap with useCallback or define outside component +4. **Object/array dependencies** - Use useMemo for stable references + +## Common Patterns + +### Fetching Data +```typescript +const [data, setData] = useState(null) +const [loading, setLoading] = useState(true) +const [error, setError] = useState(null) + +useEffect(() => { + const controller = new AbortController() + + fetch('/api/data', { signal: controller.signal }) + .then(res => res.json()) + .then(setData) + .catch(setError) + .finally(() => setLoading(false)) + + return () => controller.abort() +}, []) +``` + +### Debouncing +```typescript +const [value, setValue] = useState('') +const [debouncedValue, setDebouncedValue] = useState(value) + +useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value) + }, 500) + + return () => clearTimeout(timer) +}, [value]) +``` + +### Previous Value +```typescript +const usePrevious = <T,>(value: T): T | undefined => { + const ref = useRef<T>() + useEffect(() => { + ref.current = value + }) + return ref.current +} +``` + +### Interval +```typescript +useEffect(() => { + const id = setInterval(() => { + setCount(c => c + 1) + }, 1000) + + return () => clearInterval(id) +}, []) +``` + +### Event Listeners +```typescript +useEffect(() => { + const handleResize = () => setWidth(window.innerWidth) + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) +}, []) +``` + diff --git a/.claude/skills/react/references/performance.md b/.claude/skills/react/references/performance.md new file mode 100644 index 00000000..87c3ba99 --- /dev/null +++ b/.claude/skills/react/references/performance.md @@ -0,0 +1,658 @@ +# React Performance Optimization Guide + +## Overview + +This guide covers performance optimization strategies for React 19 applications. + +## Measurement & Profiling + +### React DevTools Profiler + +Record performance data: +1. Open React DevTools +2. Go to Profiler tab +3. Click record button +4. Interact with app +5. Stop recording +6. Analyze flame graph and ranked chart + +### Profiler Component + +```typescript +import { Profiler } from 'react' + +const App = () => { + const onRender = ( + id: string, + phase: 'mount' | 'update', + actualDuration: number, + baseDuration: number, + startTime: number, + commitTime: number + ) => { + console.log({ + component: id, + phase, + actualDuration, // Time spent rendering this update + baseDuration // Estimated time without memoization + }) + } + + return ( + <Profiler id="App" onRender={onRender}> + <YourApp /> + </Profiler> + ) +} +``` + +### Performance Metrics + +```typescript +// Custom performance tracking +const startTime = performance.now() +// ... do work +const endTime = performance.now() +console.log(`Operation took ${endTime - startTime}ms`) + +// React rendering metrics +import { unstable_trace as trace } from 'react' + +trace('expensive-operation', async () => { + await performExpensiveOperation() +}) +``` + +## Memoization Strategies + +### React.memo + +Prevent unnecessary re-renders: + +```typescript +// Basic memoization +const ExpensiveComponent = memo(({ data }: Props) => { + return <div>{processData(data)}</div> +}) + +// Custom comparison +const MemoizedComponent = memo( + ({ user }: Props) => <UserCard user={user} />, + (prevProps, nextProps) => { + // Return true if props are equal (skip render) + return prevProps.user.id === nextProps.user.id + } +) +``` + +**When to use:** +- Component renders often with same props +- Rendering is expensive +- Component receives complex prop objects + +**When NOT to use:** +- Props change frequently +- Component is already fast +- Premature optimization + +### useMemo + +Memoize computed values: + +```typescript +const SortedList = ({ items, filter }: Props) => { + // Without memoization - runs every render + const filteredItems = items.filter(item => item.type === filter) + const sortedItems = filteredItems.sort((a, b) => a.name.localeCompare(b.name)) + + // With memoization - only runs when dependencies change + const sortedFilteredItems = useMemo(() => { + const filtered = items.filter(item => item.type === filter) + return filtered.sort((a, b) => a.name.localeCompare(b.name)) + }, [items, filter]) + + return ( + <ul> + {sortedFilteredItems.map(item => ( + <li key={item.id}>{item.name}</li> + ))} + </ul> + ) +} +``` + +**When to use:** +- Expensive calculations (sorting, filtering large arrays) +- Creating stable object references +- Computed values used as dependencies + +### useCallback + +Memoize callback functions: + +```typescript +const Parent = () => { + const [count, setCount] = useState(0) + + // Without useCallback - new function every render + const handleClick = () => { + setCount(c => c + 1) + } + + // With useCallback - stable function reference + const handleClickMemo = useCallback(() => { + setCount(c => c + 1) + }, []) + + return <MemoizedChild onClick={handleClickMemo} /> +} + +const MemoizedChild = memo(({ onClick }: Props) => { + return <button onClick={onClick}>Click</button> +}) +``` + +**When to use:** +- Passing callbacks to memoized components +- Callback is used in dependency array +- Callback is expensive to create + +## React Compiler (Automatic Optimization) + +### Enable React Compiler + +React 19 can automatically optimize without manual memoization: + +```javascript +// babel.config.js +module.exports = { + plugins: [ + ['react-compiler', { + compilationMode: 'all', // Optimize all components + }] + ] +} +``` + +### Compilation Modes + +```javascript +{ + compilationMode: 'annotation', // Only components with "use memo" + compilationMode: 'all', // All components (recommended) + compilationMode: 'infer' // Based on component complexity +} +``` + +### Directives + +```typescript +// Force memoization +'use memo' +const Component = ({ data }: Props) => { + return <div>{data}</div> +} + +// Prevent memoization +'use no memo' +const SimpleComponent = ({ text }: Props) => { + return <span>{text}</span> +} +``` + +## State Management Optimization + +### State Colocation + +Keep state as close as possible to where it's used: + +```typescript +// Bad - state too high +const App = () => { + const [showModal, setShowModal] = useState(false) + + return ( + <> + <Header /> + <Content /> + <Modal show={showModal} onClose={() => setShowModal(false)} /> + </> + ) +} + +// Good - state colocated +const App = () => { + return ( + <> + <Header /> + <Content /> + <ModalContainer /> + </> + ) +} + +const ModalContainer = () => { + const [showModal, setShowModal] = useState(false) + + return <Modal show={showModal} onClose={() => setShowModal(false)} /> +} +``` + +### Split Context + +Avoid unnecessary re-renders by splitting context: + +```typescript +// Bad - single context causes all consumers to re-render +const AppContext = createContext({ user, theme, settings }) + +// Good - split into separate contexts +const UserContext = createContext(user) +const ThemeContext = createContext(theme) +const SettingsContext = createContext(settings) +``` + +### Context with useMemo + +```typescript +const ThemeProvider = ({ children }: Props) => { + const [theme, setTheme] = useState('light') + + // Memoize context value to prevent unnecessary re-renders + const value = useMemo(() => ({ + theme, + setTheme + }), [theme]) + + return ( + <ThemeContext.Provider value={value}> + {children} + </ThemeContext.Provider> + ) +} +``` + +## Code Splitting & Lazy Loading + +### React.lazy + +Split components into separate bundles: + +```typescript +import { lazy, Suspense } from 'react' + +// Lazy load components +const Dashboard = lazy(() => import('./Dashboard')) +const Settings = lazy(() => import('./Settings')) +const Profile = lazy(() => import('./Profile')) + +const App = () => { + return ( + <Suspense fallback={<Loading />}> + <Routes> + <Route path="/dashboard" element={<Dashboard />} /> + <Route path="/settings" element={<Settings />} /> + <Route path="/profile" element={<Profile />} /> + </Routes> + </Suspense> + ) +} +``` + +### Route-based Splitting + +```typescript +// App.tsx +const routes = [ + { path: '/', component: lazy(() => import('./pages/Home')) }, + { path: '/about', component: lazy(() => import('./pages/About')) }, + { path: '/products', component: lazy(() => import('./pages/Products')) }, +] + +const App = () => ( + <Suspense fallback={<PageLoader />}> + <Routes> + {routes.map(({ path, component: Component }) => ( + <Route key={path} path={path} element={<Component />} /> + ))} + </Routes> + </Suspense> +) +``` + +### Component-based Splitting + +```typescript +// Split expensive components +const HeavyChart = lazy(() => import('./HeavyChart')) + +const Dashboard = () => { + const [showChart, setShowChart] = useState(false) + + return ( + <> + <button onClick={() => setShowChart(true)}> + Load Chart + </button> + {showChart && ( + <Suspense fallback={<ChartSkeleton />}> + <HeavyChart /> + </Suspense> + )} + </> + ) +} +``` + +## List Rendering Optimization + +### Keys + +Always use stable, unique keys: + +```typescript +// Bad - index as key (causes issues on reorder/insert) +{items.map((item, index) => ( + <Item key={index} data={item} /> +))} + +// Good - unique ID as key +{items.map(item => ( + <Item key={item.id} data={item} /> +))} + +// For static lists without IDs +{items.map(item => ( + <Item key={`${item.name}-${item.category}`} data={item} /> +))} +``` + +### Virtualization + +For long lists, render only visible items: + +```typescript +import { useVirtualizer } from '@tanstack/react-virtual' + +const VirtualList = ({ items }: { items: Item[] }) => { + const parentRef = useRef<HTMLDivElement>(null) + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, // Estimated item height + overscan: 5 // Render 5 extra items above/below viewport + }) + + return ( + <div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}> + <div + style={{ + height: `${virtualizer.getTotalSize()}px`, + position: 'relative' + }} + > + {virtualizer.getVirtualItems().map(virtualItem => ( + <div + key={virtualItem.key} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)` + }} + > + <Item data={items[virtualItem.index]} /> + </div> + ))} + </div> + </div> + ) +} +``` + +### Pagination + +```typescript +const PaginatedList = ({ items }: Props) => { + const [page, setPage] = useState(1) + const itemsPerPage = 20 + + const paginatedItems = useMemo(() => { + const start = (page - 1) * itemsPerPage + const end = start + itemsPerPage + return items.slice(start, end) + }, [items, page, itemsPerPage]) + + return ( + <> + {paginatedItems.map(item => ( + <Item key={item.id} data={item} /> + ))} + <Pagination + page={page} + total={Math.ceil(items.length / itemsPerPage)} + onChange={setPage} + /> + </> + ) +} +``` + +## Transitions & Concurrent Features + +### useTransition + +Keep UI responsive during expensive updates: + +```typescript +const SearchPage = () => { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isPending, startTransition] = useTransition() + + const handleSearch = (value: string) => { + setQuery(value) // Urgent - update input immediately + + // Non-urgent - can be interrupted + startTransition(() => { + const filtered = expensiveFilter(items, value) + setResults(filtered) + }) + } + + return ( + <> + <input value={query} onChange={e => handleSearch(e.target.value)} /> + {isPending && <Spinner />} + <ResultsList results={results} /> + </> + ) +} +``` + +### useDeferredValue + +Defer non-urgent renders: + +```typescript +const SearchPage = () => { + const [query, setQuery] = useState('') + const deferredQuery = useDeferredValue(query) + + // Input updates immediately + // Results update with deferred value (can be interrupted) + const results = useMemo(() => { + return expensiveFilter(items, deferredQuery) + }, [deferredQuery]) + + return ( + <> + <input value={query} onChange={e => setQuery(e.target.value)} /> + <ResultsList results={results} /> + </> + ) +} +``` + +## Image & Asset Optimization + +### Lazy Load Images + +```typescript +const LazyImage = ({ src, alt }: Props) => { + const [isLoaded, setIsLoaded] = useState(false) + + return ( + <div className="relative"> + {!isLoaded && <ImageSkeleton />} + <img + src={src} + alt={alt} + loading="lazy" // Native lazy loading + onLoad={() => setIsLoaded(true)} + className={isLoaded ? 'opacity-100' : 'opacity-0'} + /> + </div> + ) +} +``` + +### Next.js Image Component + +```typescript +import Image from 'next/image' + +const OptimizedImage = () => ( + <Image + src="/hero.jpg" + alt="Hero" + width={800} + height={600} + priority // Load immediately for above-fold images + placeholder="blur" + blurDataURL="data:image/jpeg;base64,..." + /> +) +``` + +## Bundle Size Optimization + +### Tree Shaking + +Import only what you need: + +```typescript +// Bad - imports entire library +import _ from 'lodash' + +// Good - import only needed functions +import debounce from 'lodash/debounce' +import throttle from 'lodash/throttle' + +// Even better - use native methods when possible +const debounce = (fn, delay) => { + let timeoutId + return (...args) => { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => fn(...args), delay) + } +} +``` + +### Analyze Bundle + +```bash +# Next.js +ANALYZE=true npm run build + +# Create React App +npm install --save-dev webpack-bundle-analyzer +``` + +### Dynamic Imports + +```typescript +// Load library only when needed +const handleExport = async () => { + const { jsPDF } = await import('jspdf') + const doc = new jsPDF() + doc.save('report.pdf') +} +``` + +## Common Performance Pitfalls + +### 1. Inline Object Creation + +```typescript +// Bad - new object every render +<Component style={{ margin: 10 }} /> + +// Good - stable reference +const style = { margin: 10 } +<Component style={style} /> + +// Or use useMemo +const style = useMemo(() => ({ margin: 10 }), []) +``` + +### 2. Inline Functions + +```typescript +// Bad - new function every render (if child is memoized) +<MemoizedChild onClick={() => handleClick(id)} /> + +// Good +const handleClickMemo = useCallback(() => handleClick(id), [id]) +<MemoizedChild onClick={handleClickMemo} /> +``` + +### 3. Spreading Props + +```typescript +// Bad - causes re-renders even when props unchanged +<Component {...props} /> + +// Good - pass only needed props +<Component value={props.value} onChange={props.onChange} /> +``` + +### 4. Large Context + +```typescript +// Bad - everything re-renders on any state change +const AppContext = createContext({ user, theme, cart, settings, ... }) + +// Good - split into focused contexts +const UserContext = createContext(user) +const ThemeContext = createContext(theme) +const CartContext = createContext(cart) +``` + +## Performance Checklist + +- [ ] Measure before optimizing (use Profiler) +- [ ] Use React DevTools to identify slow components +- [ ] Implement code splitting for large routes +- [ ] Lazy load below-the-fold content +- [ ] Virtualize long lists +- [ ] Memoize expensive calculations +- [ ] Split large contexts +- [ ] Colocate state close to usage +- [ ] Use transitions for non-urgent updates +- [ ] Optimize images and assets +- [ ] Analyze and minimize bundle size +- [ ] Remove console.logs in production +- [ ] Use production build for testing +- [ ] Monitor real-world performance metrics + +## References + +- React Performance: https://react.dev/learn/render-and-commit +- React Profiler: https://react.dev/reference/react/Profiler +- React Compiler: https://react.dev/reference/react-compiler +- Web Vitals: https://web.dev/vitals/ + diff --git a/.claude/skills/react/references/server-components.md b/.claude/skills/react/references/server-components.md new file mode 100644 index 00000000..3db49801 --- /dev/null +++ b/.claude/skills/react/references/server-components.md @@ -0,0 +1,656 @@ +# React Server Components & Server Functions + +## Overview + +React Server Components (RSC) allow components to render on the server, improving performance and enabling direct data access. Server Functions allow client components to call server-side functions. + +## Server Components + +### What are Server Components? + +Components that run **only on the server**: +- Can access databases directly +- Zero bundle size (code stays on server) +- Better performance (less JavaScript to client) +- Automatic code splitting + +### Creating Server Components + +```typescript +// app/products/page.tsx +// Server Component by default in App Router + +import { db } from '@/lib/db' + +const ProductsPage = async () => { + // Direct database access + const products = await db.product.findMany({ + where: { active: true }, + include: { category: true } + }) + + return ( + <div> + <h1>Products</h1> + {products.map(product => ( + <ProductCard key={product.id} product={product} /> + ))} + </div> + ) +} + +export default ProductsPage +``` + +### Server Component Rules + +**Can do:** +- Access databases and APIs directly +- Use server-only modules (fs, path, etc.) +- Keep secrets secure (API keys, tokens) +- Reduce client bundle size +- Use async/await at top level + +**Cannot do:** +- Use hooks (useState, useEffect, etc.) +- Use browser APIs (window, document) +- Attach event handlers (onClick, etc.) +- Use Context + +### Mixing Server and Client Components + +```typescript +// Server Component (default) +const Page = async () => { + const data = await fetchData() + + return ( + <div> + <ServerComponent data={data} /> + {/* Client component for interactivity */} + <ClientComponent initialData={data} /> + </div> + ) +} + +// Client Component +'use client' + +import { useState } from 'react' + +const ClientComponent = ({ initialData }) => { + const [count, setCount] = useState(0) + + return ( + <button onClick={() => setCount(c => c + 1)}> + {count} + </button> + ) +} +``` + +### Server Component Patterns + +#### Data Fetching +```typescript +// app/user/[id]/page.tsx +interface PageProps { + params: { id: string } +} + +const UserPage = async ({ params }: PageProps) => { + const user = await db.user.findUnique({ + where: { id: params.id } + }) + + if (!user) { + notFound() // Next.js 404 + } + + return <UserProfile user={user} /> +} +``` + +#### Parallel Data Fetching +```typescript +const DashboardPage = async () => { + // Fetch in parallel + const [user, orders, stats] = await Promise.all([ + fetchUser(), + fetchOrders(), + fetchStats() + ]) + + return ( + <> + <UserHeader user={user} /> + <OrdersList orders={orders} /> + <StatsWidget stats={stats} /> + </> + ) +} +``` + +#### Streaming with Suspense +```typescript +const Page = () => { + return ( + <> + <Header /> + <Suspense fallback={<ProductsSkeleton />}> + <Products /> + </Suspense> + <Suspense fallback={<ReviewsSkeleton />}> + <Reviews /> + </Suspense> + </> + ) +} + +const Products = async () => { + const products = await fetchProducts() // Slow query + return <ProductsList products={products} /> +} +``` + +## Server Functions (Server Actions) + +### What are Server Functions? + +Functions that run on the server but can be called from client components: +- Marked with `'use server'` directive +- Can mutate data +- Integrated with forms +- Type-safe with TypeScript + +### Creating Server Functions + +#### File-level directive +```typescript +// app/actions.ts +'use server' + +import { db } from '@/lib/db' +import { revalidatePath } from 'next/cache' + +export async function createProduct(formData: FormData) { + const name = formData.get('name') as string + const price = Number(formData.get('price')) + + const product = await db.product.create({ + data: { name, price } + }) + + revalidatePath('/products') + return product +} + +export async function deleteProduct(id: string) { + await db.product.delete({ where: { id } }) + revalidatePath('/products') +} +``` + +#### Function-level directive +```typescript +// Inside a Server Component +const MyComponent = async () => { + async function handleSubmit(formData: FormData) { + 'use server' + const email = formData.get('email') as string + await saveEmail(email) + } + + return <form action={handleSubmit}>...</form> +} +``` + +### Using Server Functions + +#### With Forms +```typescript +'use client' + +import { createProduct } from './actions' + +const ProductForm = () => { + return ( + <form action={createProduct}> + <input name="name" required /> + <input name="price" type="number" required /> + <button type="submit">Create</button> + </form> + ) +} +``` + +#### With useActionState +```typescript +'use client' + +import { useActionState } from 'react' +import { createProduct } from './actions' + +type FormState = { + message: string + success: boolean +} | null + +const ProductForm = () => { + const [state, formAction, isPending] = useActionState<FormState>( + async (previousState, formData: FormData) => { + try { + await createProduct(formData) + return { message: 'Product created!', success: true } + } catch (error) { + return { message: 'Failed to create product', success: false } + } + }, + null + ) + + return ( + <form action={formAction}> + <input name="name" required /> + <input name="price" type="number" required /> + <button disabled={isPending}> + {isPending ? 'Creating...' : 'Create'} + </button> + {state?.message && ( + <p className={state.success ? 'text-green-600' : 'text-red-600'}> + {state.message} + </p> + )} + </form> + ) +} +``` + +#### Programmatic Invocation +```typescript +'use client' + +import { deleteProduct } from './actions' + +const DeleteButton = ({ productId }: { productId: string }) => { + const [isPending, setIsPending] = useState(false) + + const handleDelete = async () => { + setIsPending(true) + try { + await deleteProduct(productId) + } catch (error) { + console.error(error) + } finally { + setIsPending(false) + } + } + + return ( + <button onClick={handleDelete} disabled={isPending}> + {isPending ? 'Deleting...' : 'Delete'} + </button> + ) +} +``` + +### Server Function Patterns + +#### Validation with Zod +```typescript +'use server' + +import { z } from 'zod' + +const ProductSchema = z.object({ + name: z.string().min(3), + price: z.number().positive(), + description: z.string().optional() +}) + +export async function createProduct(formData: FormData) { + const rawData = { + name: formData.get('name'), + price: Number(formData.get('price')), + description: formData.get('description') + } + + // Validate + const result = ProductSchema.safeParse(rawData) + if (!result.success) { + return { + success: false, + errors: result.error.flatten().fieldErrors + } + } + + // Create product + const product = await db.product.create({ + data: result.data + }) + + revalidatePath('/products') + return { success: true, product } +} +``` + +#### Authentication Check +```typescript +'use server' + +import { auth } from '@/lib/auth' +import { redirect } from 'next/navigation' + +export async function createOrder(formData: FormData) { + const session = await auth() + + if (!session?.user) { + redirect('/login') + } + + const order = await db.order.create({ + data: { + userId: session.user.id, + // ... other fields + } + }) + + return order +} +``` + +#### Error Handling +```typescript +'use server' + +export async function updateProfile(formData: FormData) { + try { + const userId = await getCurrentUserId() + + const profile = await db.user.update({ + where: { id: userId }, + data: { + name: formData.get('name') as string, + bio: formData.get('bio') as string + } + }) + + revalidatePath('/profile') + return { success: true, profile } + } catch (error) { + console.error('Failed to update profile:', error) + return { + success: false, + error: 'Failed to update profile. Please try again.' + } + } +} +``` + +#### Optimistic Updates +```typescript +'use client' + +import { useOptimistic } from 'react' +import { likePost } from './actions' + +const Post = ({ post }: { post: Post }) => { + const [optimisticLikes, addOptimisticLike] = useOptimistic( + post.likes, + (currentLikes) => currentLikes + 1 + ) + + const handleLike = async () => { + addOptimisticLike(null) + await likePost(post.id) + } + + return ( + <div> + <p>{post.content}</p> + <button onClick={handleLike}> + ❤️ {optimisticLikes} + </button> + </div> + ) +} +``` + +## Data Mutations & Revalidation + +### revalidatePath +Invalidate cached data for a path: + +```typescript +'use server' + +import { revalidatePath } from 'next/cache' + +export async function createPost(formData: FormData) { + await db.post.create({ data: {...} }) + + // Revalidate the posts page + revalidatePath('/posts') + + // Revalidate with layout + revalidatePath('/posts', 'layout') +} +``` + +### revalidateTag +Invalidate cached data by tag: + +```typescript +'use server' + +import { revalidateTag } from 'next/cache' + +export async function updateProduct(id: string, data: ProductData) { + await db.product.update({ where: { id }, data }) + + // Revalidate all queries tagged with 'products' + revalidateTag('products') +} +``` + +### redirect +Redirect after mutation: + +```typescript +'use server' + +import { redirect } from 'next/navigation' + +export async function createPost(formData: FormData) { + const post = await db.post.create({ data: {...} }) + + // Redirect to the new post + redirect(`/posts/${post.id}`) +} +``` + +## Caching with Server Components + +### cache Function +Deduplicate requests within a render: + +```typescript +import { cache } from 'react' + +export const getUser = cache(async (id: string) => { + return await db.user.findUnique({ where: { id } }) +}) + +// Called multiple times but only fetches once per render +const Page = async () => { + const user1 = await getUser('123') + const user2 = await getUser('123') // Uses cached result + + return <div>...</div> +} +``` + +### Next.js fetch Caching +```typescript +// Cached by default +const data = await fetch('https://api.example.com/data') + +// Revalidate every 60 seconds +const data = await fetch('https://api.example.com/data', { + next: { revalidate: 60 } +}) + +// Never cache +const data = await fetch('https://api.example.com/data', { + cache: 'no-store' +}) + +// Tag for revalidation +const data = await fetch('https://api.example.com/data', { + next: { tags: ['products'] } +}) +``` + +## Best Practices + +### 1. Component Placement +- Keep interactive components client-side +- Use server components for data fetching +- Place 'use client' as deep as possible in tree + +### 2. Data Fetching +- Fetch in parallel when possible +- Use Suspense for streaming +- Cache expensive operations + +### 3. Server Functions +- Validate all inputs +- Check authentication/authorization +- Handle errors gracefully +- Return serializable data only + +### 4. Performance +- Minimize client JavaScript +- Use streaming for slow queries +- Implement proper caching +- Optimize database queries + +### 5. Security +- Never expose secrets to client +- Validate server function inputs +- Use environment variables +- Implement rate limiting + +## Common Patterns + +### Layout with Dynamic Data +```typescript +// app/layout.tsx +const RootLayout = async ({ children }: { children: React.ReactNode }) => { + const user = await getCurrentUser() + + return ( + <html> + <body> + <Header user={user} /> + {children} + <Footer /> + </body> + </html> + ) +} +``` + +### Loading States +```typescript +// app/products/loading.tsx +export default function Loading() { + return <ProductsSkeleton /> +} + +// app/products/page.tsx +const ProductsPage = async () => { + const products = await fetchProducts() + return <ProductsList products={products} /> +} +``` + +### Error Boundaries +```typescript +// app/products/error.tsx +'use client' + +export default function Error({ + error, + reset +}: { + error: Error + reset: () => void +}) { + return ( + <div> + <h2>Something went wrong!</h2> + <p>{error.message}</p> + <button onClick={reset}>Try again</button> + </div> + ) +} +``` + +### Search with Server Functions +```typescript +'use client' + +import { searchProducts } from './actions' +import { useDeferredValue, useState, useEffect } from 'react' + +const SearchPage = () => { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const deferredQuery = useDeferredValue(query) + + useEffect(() => { + if (deferredQuery) { + searchProducts(deferredQuery).then(setResults) + } + }, [deferredQuery]) + + return ( + <> + <input + value={query} + onChange={e => setQuery(e.target.value)} + /> + <ResultsList results={results} /> + </> + ) +} +``` + +## Troubleshooting + +### Common Issues + +1. **"Cannot use hooks in Server Component"** + - Add 'use client' directive + - Move state logic to client component + +2. **"Functions cannot be passed to Client Components"** + - Use Server Functions instead + - Pass data, not functions + +3. **Hydration mismatches** + - Ensure server and client render same HTML + - Use useEffect for browser-only code + +4. **Slow initial load** + - Implement Suspense boundaries + - Use streaming rendering + - Optimize database queries + +## References + +- React Server Components: https://react.dev/reference/rsc/server-components +- Server Functions: https://react.dev/reference/rsc/server-functions +- Next.js App Router: https://nextjs.org/docs/app + diff --git a/.claude/skills/rollup/SKILL.md b/.claude/skills/rollup/SKILL.md new file mode 100644 index 00000000..cd694cda --- /dev/null +++ b/.claude/skills/rollup/SKILL.md @@ -0,0 +1,899 @@ +--- +name: rollup +description: This skill should be used when working with Rollup module bundler, including configuration, plugins, code splitting, and build optimization. Provides comprehensive knowledge of Rollup patterns, plugin development, and bundling strategies. +--- + +# Rollup Skill + +This skill provides comprehensive knowledge and patterns for working with Rollup module bundler effectively. + +## When to Use This Skill + +Use this skill when: +- Configuring Rollup for web applications +- Setting up Rollup for library builds +- Working with Rollup plugins +- Implementing code splitting +- Optimizing bundle size +- Troubleshooting build issues +- Integrating Rollup with Svelte or other frameworks +- Developing custom Rollup plugins + +## Core Concepts + +### Rollup Overview + +Rollup is a module bundler that: +- **Tree-shakes by default** - Removes unused code automatically +- **ES module focused** - Native ESM output support +- **Plugin-based** - Extensible architecture +- **Multiple outputs** - Generate multiple formats from single input +- **Code splitting** - Dynamic imports for lazy loading +- **Scope hoisting** - Flattens modules for smaller bundles + +### Basic Configuration + +```javascript +// rollup.config.js +export default { + input: 'src/main.js', + output: { + file: 'dist/bundle.js', + format: 'esm' + } +}; +``` + +### Output Formats + +Rollup supports multiple output formats: + +| Format | Description | Use Case | +|--------|-------------|----------| +| `esm` | ES modules | Modern browsers, bundlers | +| `cjs` | CommonJS | Node.js | +| `iife` | Self-executing function | Script tags | +| `umd` | Universal Module Definition | CDN, both environments | +| `amd` | Asynchronous Module Definition | RequireJS | +| `system` | SystemJS | SystemJS loader | + +## Configuration + +### Full Configuration Options + +```javascript +// rollup.config.js +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import terser from '@rollup/plugin-terser'; + +const production = !process.env.ROLLUP_WATCH; + +export default { + // Entry point(s) + input: 'src/main.js', + + // Output configuration + output: { + // Output file or directory + file: 'dist/bundle.js', + // Or for code splitting: + // dir: 'dist', + + // Output format + format: 'esm', + + // Name for IIFE/UMD builds + name: 'MyBundle', + + // Sourcemap generation + sourcemap: true, + + // Global variables for external imports (IIFE/UMD) + globals: { + jquery: '$' + }, + + // Banner/footer comments + banner: '/* My library v1.0.0 */', + footer: '/* End of bundle */', + + // Chunk naming for code splitting + chunkFileNames: '[name]-[hash].js', + entryFileNames: '[name].js', + + // Manual chunks for code splitting + manualChunks: { + vendor: ['lodash', 'moment'] + }, + + // Interop mode for default exports + interop: 'auto', + + // Preserve modules structure + preserveModules: false, + + // Exports mode + exports: 'auto' // 'default', 'named', 'none', 'auto' + }, + + // External dependencies (not bundled) + external: ['lodash', /^node:/], + + // Plugin array + plugins: [ + resolve({ + browser: true, + dedupe: ['svelte'] + }), + commonjs(), + production && terser() + ], + + // Watch mode options + watch: { + include: 'src/**', + exclude: 'node_modules/**', + clearScreen: false + }, + + // Warning handling + onwarn(warning, warn) { + // Skip certain warnings + if (warning.code === 'CIRCULAR_DEPENDENCY') return; + warn(warning); + }, + + // Preserve entry signatures for code splitting + preserveEntrySignatures: 'strict', + + // Treeshake options + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false + } +}; +``` + +### Multiple Outputs + +```javascript +export default { + input: 'src/main.js', + output: [ + { + file: 'dist/bundle.esm.js', + format: 'esm' + }, + { + file: 'dist/bundle.cjs.js', + format: 'cjs' + }, + { + file: 'dist/bundle.umd.js', + format: 'umd', + name: 'MyLibrary' + } + ] +}; +``` + +### Multiple Entry Points + +```javascript +export default { + input: { + main: 'src/main.js', + utils: 'src/utils.js' + }, + output: { + dir: 'dist', + format: 'esm' + } +}; +``` + +### Array of Configurations + +```javascript +export default [ + { + input: 'src/main.js', + output: { file: 'dist/main.js', format: 'esm' } + }, + { + input: 'src/worker.js', + output: { file: 'dist/worker.js', format: 'iife' } + } +]; +``` + +## Essential Plugins + +### @rollup/plugin-node-resolve + +Resolve node_modules imports: + +```javascript +import resolve from '@rollup/plugin-node-resolve'; + +export default { + plugins: [ + resolve({ + // Resolve browser field in package.json + browser: true, + + // Prefer built-in modules + preferBuiltins: true, + + // Only resolve these extensions + extensions: ['.mjs', '.js', '.json', '.node'], + + // Dedupe packages (important for Svelte) + dedupe: ['svelte'], + + // Main fields to check in package.json + mainFields: ['module', 'main', 'browser'], + + // Export conditions + exportConditions: ['svelte', 'browser', 'module', 'import'] + }) + ] +}; +``` + +### @rollup/plugin-commonjs + +Convert CommonJS to ES modules: + +```javascript +import commonjs from '@rollup/plugin-commonjs'; + +export default { + plugins: [ + commonjs({ + // Include specific modules + include: /node_modules/, + + // Exclude specific modules + exclude: ['node_modules/lodash-es/**'], + + // Ignore conditional requires + ignoreDynamicRequires: false, + + // Transform mixed ES/CJS modules + transformMixedEsModules: true, + + // Named exports for specific modules + namedExports: { + 'react': ['createElement', 'Component'] + } + }) + ] +}; +``` + +### @rollup/plugin-terser + +Minify output: + +```javascript +import terser from '@rollup/plugin-terser'; + +export default { + plugins: [ + terser({ + compress: { + drop_console: true, + drop_debugger: true + }, + mangle: true, + format: { + comments: false + } + }) + ] +}; +``` + +### rollup-plugin-svelte + +Compile Svelte components: + +```javascript +import svelte from 'rollup-plugin-svelte'; +import css from 'rollup-plugin-css-only'; + +export default { + plugins: [ + svelte({ + // Enable dev mode + dev: !production, + + // Emit CSS as a separate file + emitCss: true, + + // Preprocess (SCSS, TypeScript, etc.) + preprocess: sveltePreprocess(), + + // Compiler options + compilerOptions: { + dev: !production + }, + + // Custom element mode + customElement: false + }), + + // Extract CSS to separate file + css({ output: 'bundle.css' }) + ] +}; +``` + +### Other Common Plugins + +```javascript +import json from '@rollup/plugin-json'; +import replace from '@rollup/plugin-replace'; +import alias from '@rollup/plugin-alias'; +import image from '@rollup/plugin-image'; +import copy from 'rollup-plugin-copy'; +import livereload from 'rollup-plugin-livereload'; + +export default { + plugins: [ + // Import JSON files + json(), + + // Replace strings in code + replace({ + preventAssignment: true, + 'process.env.NODE_ENV': JSON.stringify('production'), + '__VERSION__': JSON.stringify('1.0.0') + }), + + // Path aliases + alias({ + entries: [ + { find: '@', replacement: './src' }, + { find: 'utils', replacement: './src/utils' } + ] + }), + + // Import images + image(), + + // Copy static files + copy({ + targets: [ + { src: 'public/*', dest: 'dist' } + ] + }), + + // Live reload in dev + !production && livereload('dist') + ] +}; +``` + +## Code Splitting + +### Dynamic Imports + +```javascript +// Automatically creates chunks +async function loadFeature() { + const { feature } = await import('./feature.js'); + feature(); +} +``` + +Configuration for code splitting: + +```javascript +export default { + input: 'src/main.js', + output: { + dir: 'dist', + format: 'esm', + chunkFileNames: 'chunks/[name]-[hash].js' + } +}; +``` + +### Manual Chunks + +```javascript +export default { + output: { + manualChunks: { + // Vendor chunk + vendor: ['lodash', 'moment'], + + // Or use a function for more control + manualChunks(id) { + if (id.includes('node_modules')) { + return 'vendor'; + } + } + } + } +}; +``` + +### Advanced Chunking Strategy + +```javascript +export default { + output: { + manualChunks(id, { getModuleInfo }) { + // Separate chunks by feature + if (id.includes('/features/auth/')) { + return 'auth'; + } + if (id.includes('/features/dashboard/')) { + return 'dashboard'; + } + + // Vendor chunks by package + if (id.includes('node_modules')) { + const match = id.match(/node_modules\/([^/]+)/); + if (match) { + const packageName = match[1]; + // Group small packages + const smallPackages = ['lodash', 'date-fns']; + if (smallPackages.includes(packageName)) { + return 'vendor-utils'; + } + return `vendor-${packageName}`; + } + } + } + } +}; +``` + +## Watch Mode + +### Configuration + +```javascript +export default { + watch: { + // Files to watch + include: 'src/**', + + // Files to ignore + exclude: 'node_modules/**', + + // Don't clear screen on rebuild + clearScreen: false, + + // Rebuild delay + buildDelay: 0, + + // Watch chokidar options + chokidar: { + usePolling: true + } + } +}; +``` + +### CLI Watch Mode + +```bash +# Watch mode +rollup -c -w + +# With environment variable +ROLLUP_WATCH=true rollup -c +``` + +## Plugin Development + +### Plugin Structure + +```javascript +function myPlugin(options = {}) { + return { + // Plugin name (required) + name: 'my-plugin', + + // Build hooks + options(inputOptions) { + // Modify input options + return inputOptions; + }, + + buildStart(inputOptions) { + // Called on build start + }, + + resolveId(source, importer, options) { + // Custom module resolution + if (source === 'virtual-module') { + return source; + } + return null; // Defer to other plugins + }, + + load(id) { + // Load module content + if (id === 'virtual-module') { + return 'export default "Hello"'; + } + return null; + }, + + transform(code, id) { + // Transform module code + if (id.endsWith('.txt')) { + return { + code: `export default ${JSON.stringify(code)}`, + map: null + }; + } + }, + + buildEnd(error) { + // Called when build ends + if (error) { + console.error('Build failed:', error); + } + }, + + // Output generation hooks + renderStart(outputOptions, inputOptions) { + // Called before output generation + }, + + banner() { + return '/* Custom banner */'; + }, + + footer() { + return '/* Custom footer */'; + }, + + renderChunk(code, chunk, options) { + // Transform output chunk + return code; + }, + + generateBundle(options, bundle) { + // Modify output bundle + for (const fileName in bundle) { + const chunk = bundle[fileName]; + if (chunk.type === 'chunk') { + // Modify chunk + } + } + }, + + writeBundle(options, bundle) { + // After bundle is written + }, + + closeBundle() { + // Called when bundle is closed + } + }; +} + +export default myPlugin; +``` + +### Plugin with Rollup Utils + +```javascript +import { createFilter } from '@rollup/pluginutils'; + +function myTransformPlugin(options = {}) { + const filter = createFilter(options.include, options.exclude); + + return { + name: 'my-transform', + + transform(code, id) { + if (!filter(id)) return null; + + // Transform code + const transformed = code.replace(/foo/g, 'bar'); + + return { + code: transformed, + map: null // Or generate sourcemap + }; + } + }; +} +``` + +## Svelte Integration + +### Complete Svelte Setup + +```javascript +// rollup.config.js +import svelte from 'rollup-plugin-svelte'; +import commonjs from '@rollup/plugin-commonjs'; +import resolve from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; +import css from 'rollup-plugin-css-only'; +import livereload from 'rollup-plugin-livereload'; + +const production = !process.env.ROLLUP_WATCH; + +function serve() { + let server; + + function toExit() { + if (server) server.kill(0); + } + + return { + writeBundle() { + if (server) return; + server = require('child_process').spawn( + 'npm', + ['run', 'start', '--', '--dev'], + { + stdio: ['ignore', 'inherit', 'inherit'], + shell: true + } + ); + + process.on('SIGTERM', toExit); + process.on('exit', toExit); + } + }; +} + +export default { + input: 'src/main.js', + output: { + sourcemap: true, + format: 'iife', + name: 'app', + file: 'public/build/bundle.js' + }, + plugins: [ + svelte({ + compilerOptions: { + dev: !production + } + }), + css({ output: 'bundle.css' }), + + resolve({ + browser: true, + dedupe: ['svelte'] + }), + commonjs(), + + // Dev server + !production && serve(), + !production && livereload('public'), + + // Minify in production + production && terser() + ], + watch: { + clearScreen: false + } +}; +``` + +## Best Practices + +### Bundle Optimization + +1. **Enable tree shaking** - Use ES modules +2. **Mark side effects** - Set `sideEffects` in package.json +3. **Use terser** - Minify production builds +4. **Analyze bundles** - Use rollup-plugin-visualizer +5. **Code split** - Lazy load routes and features + +### External Dependencies + +```javascript +export default { + // Don't bundle peer dependencies for libraries + external: [ + 'react', + 'react-dom', + /^lodash\// + ], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM' + } + } +}; +``` + +### Development vs Production + +```javascript +const production = !process.env.ROLLUP_WATCH; + +export default { + plugins: [ + replace({ + preventAssignment: true, + 'process.env.NODE_ENV': JSON.stringify( + production ? 'production' : 'development' + ) + }), + production && terser() + ].filter(Boolean) +}; +``` + +### Error Handling + +```javascript +export default { + onwarn(warning, warn) { + // Ignore circular dependency warnings + if (warning.code === 'CIRCULAR_DEPENDENCY') { + return; + } + + // Ignore unused external imports + if (warning.code === 'UNUSED_EXTERNAL_IMPORT') { + return; + } + + // Treat other warnings as errors + if (warning.code === 'UNRESOLVED_IMPORT') { + throw new Error(warning.message); + } + + // Use default warning handling + warn(warning); + } +}; +``` + +## Common Patterns + +### Library Build + +```javascript +import pkg from './package.json'; + +export default { + input: 'src/index.js', + external: Object.keys(pkg.peerDependencies || {}), + output: [ + { + file: pkg.main, + format: 'cjs', + sourcemap: true + }, + { + file: pkg.module, + format: 'esm', + sourcemap: true + } + ] +}; +``` + +### Application Build + +```javascript +export default { + input: 'src/main.js', + output: { + dir: 'dist', + format: 'esm', + chunkFileNames: 'chunks/[name]-[hash].js', + entryFileNames: '[name]-[hash].js', + sourcemap: true + }, + plugins: [ + // All dependencies bundled + resolve({ browser: true }), + commonjs(), + terser() + ] +}; +``` + +### Web Worker Build + +```javascript +export default [ + // Main application + { + input: 'src/main.js', + output: { + file: 'dist/main.js', + format: 'esm' + }, + plugins: [resolve(), commonjs()] + }, + // Web worker (IIFE format) + { + input: 'src/worker.js', + output: { + file: 'dist/worker.js', + format: 'iife' + }, + plugins: [resolve(), commonjs()] + } +]; +``` + +## Troubleshooting + +### Common Issues + +**Module not found:** +- Check @rollup/plugin-node-resolve is configured +- Verify package is installed +- Check `external` array + +**CommonJS module issues:** +- Add @rollup/plugin-commonjs +- Check `namedExports` configuration +- Try `transformMixedEsModules: true` + +**Circular dependencies:** +- Use `onwarn` to suppress or fix +- Refactor to break cycles +- Check import order + +**Sourcemaps not working:** +- Set `sourcemap: true` in output +- Ensure plugins pass through maps +- Check browser devtools settings + +**Large bundle size:** +- Use rollup-plugin-visualizer +- Check for duplicate dependencies +- Verify tree shaking is working +- Mark unused packages as external + +## CLI Reference + +```bash +# Basic build +rollup -c + +# Watch mode +rollup -c -w + +# Custom config +rollup -c rollup.custom.config.js + +# Output format +rollup src/main.js --format esm --file dist/bundle.js + +# Environment variables +NODE_ENV=production rollup -c + +# Silent mode +rollup -c --silent + +# Generate bundle stats +rollup -c --perf +``` + +## References + +- **Rollup Documentation**: https://rollupjs.org +- **Plugin Directory**: https://github.com/rollup/plugins +- **Awesome Rollup**: https://github.com/rollup/awesome +- **GitHub**: https://github.com/rollup/rollup + +## Related Skills + +- **svelte** - Using Rollup with Svelte +- **typescript** - TypeScript compilation with Rollup +- **nostr-tools** - Bundling Nostr applications diff --git a/.claude/skills/skill-creator/LICENSE.txt b/.claude/skills/skill-creator/LICENSE.txt new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/.claude/skills/skill-creator/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.claude/skills/skill-creator/SKILL.md b/.claude/skills/skill-creator/SKILL.md new file mode 100644 index 00000000..40699358 --- /dev/null +++ b/.claude/skills/skill-creator/SKILL.md @@ -0,0 +1,209 @@ +--- +name: skill-creator +description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations. +license: Complete terms in LICENSE.txt +--- + +# Skill Creator + +This skill provides guidance for creating effective skills. + +## About Skills + +Skills are modular, self-contained packages that extend Claude's capabilities by providing +specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific +domains or tasks—they transform Claude from a general-purpose agent into a specialized agent +equipped with procedural knowledge that no model can fully possess. + +### What Skills Provide + +1. Specialized workflows - Multi-step procedures for specific domains +2. Tool integrations - Instructions for working with specific file formats or APIs +3. Domain expertise - Company-specific knowledge, schemas, business logic +4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks + +### Anatomy of a Skill + +Every skill consists of a required SKILL.md file and optional bundled resources: + +``` +skill-name/ +├── SKILL.md (required) +│ ├── YAML frontmatter metadata (required) +│ │ ├── name: (required) +│ │ └── description: (required) +│ └── Markdown instructions (required) +└── Bundled Resources (optional) + ├── scripts/ - Executable code (Python/Bash/etc.) + ├── references/ - Documentation intended to be loaded into context as needed + └── assets/ - Files used in output (templates, icons, fonts, etc.) +``` + +#### SKILL.md (required) + +**Metadata Quality:** The `name` and `description` in YAML frontmatter determine when Claude will use the skill. Be specific about what the skill does and when to use it. Use the third-person (e.g. "This skill should be used when..." instead of "Use this skill when..."). + +#### Bundled Resources (optional) + +##### Scripts (`scripts/`) + +Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten. + +- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed +- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks +- **Benefits**: Token efficient, deterministic, may be executed without loading into context +- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments + +##### References (`references/`) + +Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking. + +- **When to include**: For documentation that Claude should reference while working +- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications +- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides +- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed +- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md +- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. + +##### Assets (`assets/`) + +Files not intended to be loaded into context, but rather used within the output Claude produces. + +- **When to include**: When the skill needs files that will be used in the final output +- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography +- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified +- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context + +### Progressive Disclosure Design Principle + +Skills use a three-level loading system to manage context efficiently: + +1. **Metadata (name + description)** - Always in context (~100 words) +2. **SKILL.md body** - When skill triggers (<5k words) +3. **Bundled resources** - As needed by Claude (Unlimited*) + +*Unlimited because scripts can be executed without reading into context window. + +## Skill Creation Process + +To create a skill, follow the "Skill Creation Process" in order, skipping steps only if there is a clear reason why they are not applicable. + +### Step 1: Understanding the Skill with Concrete Examples + +Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill. + +To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback. + +For example, when building an image-editor skill, relevant questions include: + +- "What functionality should the image-editor skill support? Editing, rotating, anything else?" +- "Can you give some examples of how this skill would be used?" +- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" +- "What would a user say that should trigger this skill?" + +To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. + +Conclude this step when there is a clear sense of the functionality the skill should support. + +### Step 2: Planning the Reusable Skill Contents + +To turn concrete examples into an effective skill, analyze each example by: + +1. Considering how to execute on the example from scratch +2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly + +Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows: + +1. Rotating a PDF requires re-writing the same code each time +2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill + +Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows: + +1. Writing a frontend webapp requires the same boilerplate HTML/React each time +2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill + +Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows: + +1. Querying BigQuery requires re-discovering the table schemas and relationships each time +2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill + +To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets. + +### Step 3: Initializing the Skill + +At this point, it is time to actually create the skill. + +Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step. + +When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. + +Usage: + +```bash +scripts/init_skill.py <skill-name> --path <output-directory> +``` + +The script: + +- Creates the skill directory at the specified path +- Generates a SKILL.md template with proper frontmatter and TODO placeholders +- Creates example resource directories: `scripts/`, `references/`, and `assets/` +- Adds example files in each directory that can be customized or deleted + +After initialization, customize or remove the generated SKILL.md and example files as needed. + +### Step 4: Edit the Skill + +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Focus on including information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively. + +#### Start with Reusable Skill Contents + +To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`. + +Also, delete any example files and directories not needed for the skill. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them. + +#### Update SKILL.md + +**Writing Style:** Write the entire skill using **imperative/infinitive form** (verb-first instructions), not second person. Use objective, instructional language (e.g., "To accomplish X, do Y" rather than "You should do X" or "If you need to do X"). This maintains consistency and clarity for AI consumption. + +To complete SKILL.md, answer the following questions: + +1. What is the purpose of the skill, in a few sentences? +2. When should the skill be used? +3. In practice, how should Claude use the skill? All reusable skill contents developed above should be referenced so that Claude knows how to use them. + +### Step 5: Packaging a Skill + +Once the skill is ready, it should be packaged into a distributable zip file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements: + +```bash +scripts/package_skill.py <path/to/skill-folder> +``` + +Optional output directory specification: + +```bash +scripts/package_skill.py <path/to/skill-folder> ./dist +``` + +The packaging script will: + +1. **Validate** the skill automatically, checking: + - YAML frontmatter format and required fields + - Skill naming conventions and directory structure + - Description completeness and quality + - File organization and resource references + +2. **Package** the skill if validation passes, creating a zip file named after the skill (e.g., `my-skill.zip`) that includes all files and maintains the proper directory structure for distribution. + +If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again. + +### Step 6: Iterate + +After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed. + +**Iteration workflow:** +1. Use the skill on real tasks +2. Notice struggles or inefficiencies +3. Identify how SKILL.md or bundled resources should be updated +4. Implement changes and test again diff --git a/.claude/skills/skill-creator/scripts/__pycache__/quick_validate.cpython-310.pyc b/.claude/skills/skill-creator/scripts/__pycache__/quick_validate.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d54036600f1253a6e476a9a3fb2305cd63b101a2 GIT binary patch literal 1675 zcmZWpU2hvj6y4b`uh))iQYfS;(jXNjZo78jp@Jx+6hftnG=LHwFrll>&cvR2y=!LH zZoOJdsNs=+fROA*{?fei)So~e5O;Q+V9}L!?wy%)=bn4!BUxK>HTeD+*4fz6w7*nm z^)sOJIsD=`Xo%L;2qJofx~Q;vS1+v5HNYB?IkdVK(uhf{U$w4HE5sh?e4jcLJ+Gc4 zQW+Th9(9Rxj*v#tyhf@E<=JYUR5!G9bfS0b-8JgcwMz`G^M?e*sKgTeNK=2y%^tYl zjafLrlOSRwNLdnN5pp(4@ma#L7_cZ3*uz5>vtbb73FQJX?rW$-J8U6oejB9y5UK9+ ztAe%Q{}O)Tz)Y&;z-b08L7UnP<!Fu;nrJ8n;Oh%*rsw+HAjSfT4PqW?sX4cZmFubb z++G+nBR9l{#0Io6uaL?D%}nCt<^=KkqzaX|h5vxmib#jlb9153tlZ)(x5|il6|&gH zx@)<;(8UpHkZZa9yGELS7-x!!4Gi7_1_5|(>5=u^o@kt1VXv21SjQo67g;KKg}=%x zD_I=!PVP)J@e8@3EPqAb1&;55+8f#C7lB~ms@TaH4oaCb=jiJv2M6sTY3Yx%^~(;9 z6SWuPm}G|Md1`LsFu^}OdU}A*coL_>AWbP>LYmJ2a}sa2?>uas#7q3mCvma8|HE^L ze64mav*KV#chu7FKV?F&xQDmY&=zL#YEV`uv^*Vf1`7P`EBwmLn_<QJC*sa_`)(_% zKl?H8vishr-cNULKhEq!$b`4DuePx0C*z3VQ;Pf3QJ=<M7zm2DqvQqUg&R>-tGI^= z>#-DU5R)ZV;3STwtqbI3y60t|ZQ(G8m3ATxc-o-=e8JNG5{=<-hag@61%eec9H(q@ znO9`jSIXI062CyZ7idqZ^zr_GGF+y)TrG%u5yhuG2nRG3czai++H1A+<Lugzx=xsh z@&J6$s&O6Umo|OLL@H#J(}4IXeVIzPU@a75sdFkV*sHKF?fq|#9~~S>tH+b^NZNu1 z9EL5C!j+W{6j_9{s<42{<&WTYN~iRPL{=0_8mGLb^43<)k95@@`DGeY`O;MyEJtc9 zdi<&J4G6zD?(~x(?F=J27<U8@J25bCCp?<A37vG>VHAuB?UZjwX}l0hOcUNIPm$>S ztR5eK`S{TGXet|*9lr=vM8zl2IJ#pv$V86eLfbS<Xk8eC5%B6@8>p@~(Jj<m_L@3i zbiS!db$lp<#lHVqDV}}wy$E`A2al#{A6~pSSm51KGQv0wf$wyWt4F-mkQLu2N$C61 z5K|%bL~!-?%Zd^@VyDH1UZpG~1!zQLX$HJEk=BT_IF%;scq*-;R9P8Pq39H<t~56b l;=99yj3c_IE~>Z%4HRrTP;?!EB%KC$E?U<ebOV`x{|nJV>sJ5( literal 0 HcmV?d00001 diff --git a/.claude/skills/skill-creator/scripts/init_skill.py b/.claude/skills/skill-creator/scripts/init_skill.py new file mode 100755 index 00000000..329ad4e5 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/init_skill.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Skill Initializer - Creates a new skill from template + +Usage: + init_skill.py <skill-name> --path <path> + +Examples: + init_skill.py my-new-skill --path skills/public + init_skill.py my-api-helper --path skills/private + init_skill.py custom-skill --path /custom/location +""" + +import sys +from pathlib import Path + + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" → "Reading" → "Creating" → "Editing" +- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" → "Merge PDFs" → "Split PDFs" → "Extract Text" +- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" → "Colors" → "Typography" → "Features" +- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" → numbered capability list +- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources + +This skill includes example resource directories that demonstrate how to organize different types of bundled resources: + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Claude's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Claude produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Any unneeded directories can be deleted.** Not every skill requires all three types of resources. +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Claude produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return ' '.join(word.capitalize() for word in skill_name.split('-')) + + +def init_skill(skill_name, path): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"❌ Error: Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"✅ Created skill directory: {skill_dir}") + except Exception as e: + print(f"❌ Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format( + skill_name=skill_name, + skill_title=skill_title + ) + + skill_md_path = skill_dir / 'SKILL.md' + try: + skill_md_path.write_text(skill_content) + print("✅ Created SKILL.md") + except Exception as e: + print(f"❌ Error creating SKILL.md: {e}") + return None + + # Create resource directories with example files + try: + # Create scripts/ directory with example script + scripts_dir = skill_dir / 'scripts' + scripts_dir.mkdir(exist_ok=True) + example_script = scripts_dir / 'example.py' + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("✅ Created scripts/example.py") + + # Create references/ directory with example reference doc + references_dir = skill_dir / 'references' + references_dir.mkdir(exist_ok=True) + example_reference = references_dir / 'api_reference.md' + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("✅ Created references/api_reference.md") + + # Create assets/ directory with example asset placeholder + assets_dir = skill_dir / 'assets' + assets_dir.mkdir(exist_ok=True) + example_asset = assets_dir / 'example_asset.txt' + example_asset.write_text(EXAMPLE_ASSET) + print("✅ Created assets/example_asset.txt") + except Exception as e: + print(f"❌ Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + print("2. Customize or delete the example files in scripts/, references/, and assets/") + print("3. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + if len(sys.argv) < 4 or sys.argv[2] != '--path': + print("Usage: init_skill.py <skill-name> --path <path>") + print("\nSkill name requirements:") + print(" - Hyphen-case identifier (e.g., 'data-analyzer')") + print(" - Lowercase letters, digits, and hyphens only") + print(" - Max 40 characters") + print(" - Must match directory name exactly") + print("\nExamples:") + print(" init_skill.py my-new-skill --path skills/public") + print(" init_skill.py my-api-helper --path skills/private") + print(" init_skill.py custom-skill --path /custom/location") + sys.exit(1) + + skill_name = sys.argv[1] + path = sys.argv[3] + + print(f"🚀 Initializing skill: {skill_name}") + print(f" Location: {path}") + print() + + result = init_skill(skill_name, path) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/package_skill.py b/.claude/skills/skill-creator/scripts/package_skill.py new file mode 100755 index 00000000..3ee8e8e9 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable zip file of a skill folder + +Usage: + python utils/package_skill.py <path/to/skill-folder> [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import sys +import zipfile +from pathlib import Path +from quick_validate import validate_skill + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a zip file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the zip file (defaults to current directory) + + Returns: + Path to the created zip file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"❌ Error: Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"❌ Error: Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"❌ Error: SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("🔍 Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"❌ Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"✅ {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + zip_filename = output_path / f"{skill_name}.zip" + + # Create the zip file + try: + with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory + for file_path in skill_path.rglob('*'): + if file_path.is_file(): + # Calculate the relative path within the zip + arcname = file_path.relative_to(skill_path.parent) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n✅ Successfully packaged skill to: {zip_filename}") + return zip_filename + + except Exception as e: + print(f"❌ Error creating zip file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py <path/to/skill-folder> [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"📦 Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/quick_validate.py b/.claude/skills/skill-creator/scripts/quick_validate.py new file mode 100755 index 00000000..6fa6c636 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import sys +import os +import re +from pathlib import Path + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + # Check SKILL.md exists + skill_md = skill_path / 'SKILL.md' + if not skill_md.exists(): + return False, "SKILL.md not found" + + # Read and validate frontmatter + content = skill_md.read_text() + if not content.startswith('---'): + return False, "No YAML frontmatter found" + + # Extract frontmatter + match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter = match.group(1) + + # Check required fields + if 'name:' not in frontmatter: + return False, "Missing 'name' in frontmatter" + if 'description:' not in frontmatter: + return False, "Missing 'description' in frontmatter" + + # Extract name for validation + name_match = re.search(r'name:\s*(.+)', frontmatter) + if name_match: + name = name_match.group(1).strip() + # Check naming convention (hyphen-case: lowercase with hyphens) + if not re.match(r'^[a-z0-9-]+$', name): + return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)" + if name.startswith('-') or name.endswith('-') or '--' in name: + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + + # Extract and validate description + desc_match = re.search(r'description:\s*(.+)', frontmatter) + if desc_match: + description = desc_match.group(1).strip() + # Check for angle brackets + if '<' in description or '>' in description: + return False, "Description cannot contain angle brackets (< or >)" + + return True, "Skill is valid!" + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py <skill_directory>") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) \ No newline at end of file diff --git a/.claude/skills/svelte/SKILL.md b/.claude/skills/svelte/SKILL.md new file mode 100644 index 00000000..0d87233a --- /dev/null +++ b/.claude/skills/svelte/SKILL.md @@ -0,0 +1,1004 @@ +--- +name: svelte +description: This skill should be used when working with Svelte 3/4, including components, reactivity, stores, lifecycle, and component communication. Provides comprehensive knowledge of Svelte patterns, best practices, and reactive programming concepts. +--- + +# Svelte 3/4 Skill + +This skill provides comprehensive knowledge and patterns for working with Svelte effectively in modern web applications. + +## When to Use This Skill + +Use this skill when: +- Building Svelte applications and components +- Working with Svelte reactivity and stores +- Implementing component communication patterns +- Managing component lifecycle +- Optimizing Svelte application performance +- Troubleshooting Svelte-specific issues +- Working with Svelte transitions and animations +- Integrating with external libraries + +## Core Concepts + +### Svelte Overview + +Svelte is a compiler-based frontend framework that: +- **Compiles to vanilla JavaScript** - No runtime library shipped to browser +- **Reactive by default** - Variables are reactive, assignments trigger updates +- **Component-based** - Single-file components with `.svelte` extension +- **CSS scoping** - Styles are scoped to components by default +- **Built-in transitions** - Animation primitives included +- **Two-way binding** - Simple data binding with `bind:` + +### Component Structure + +Svelte components have three sections: + +```svelte +<script> + // JavaScript logic + let count = 0; + + function increment() { + count += 1; + } +</script> + +<style> + /* Scoped CSS */ + button { + background: #ff3e00; + color: white; + } +</style> + +<!-- HTML template --> +<button on:click={increment}> + Clicked {count} times +</button> +``` + +## Reactivity + +### Reactive Declarations + +Use `$:` for reactive statements and computed values: + +```svelte +<script> + let count = 0; + + // Reactive declaration - recomputes when count changes + $: doubled = count * 2; + + // Reactive statement - runs when dependencies change + $: console.log(`count is ${count}`); + + // Reactive block + $: { + console.log(`count is ${count}`); + console.log(`doubled is ${doubled}`); + } + + // Reactive if statement + $: if (count >= 10) { + alert('count is high!'); + count = 0; + } +</script> +``` + +### Reactive Assignments + +Reactivity is triggered by assignments: + +```svelte +<script> + let numbers = [1, 2, 3]; + + function addNumber() { + // This triggers reactivity + numbers = [...numbers, numbers.length + 1]; + + // This also works + numbers.push(numbers.length + 1); + numbers = numbers; + } + + let obj = { foo: 'bar' }; + + function updateObject() { + // Reassignment triggers update + obj.foo = 'baz'; + obj = obj; + + // Or use spread + obj = { ...obj, foo: 'baz' }; + } +</script> +``` + +**Key Points:** +- Array methods like `push`, `pop` need reassignment to trigger updates +- Object property changes need reassignment +- Use spread operator for immutable updates + +## Props + +### Declaring Props + +```svelte +<script> + // Basic prop + export let name; + + // Prop with default value + export let greeting = 'Hello'; + + // Readonly prop (convention) + export let readonly count = 0; +</script> + +<p>{greeting}, {name}!</p> +``` + +### Spread Props + +```svelte +<script> + // Forward all props to child + export let info = {}; +</script> + +<Child {...info} /> + +<!-- Or forward unknown props --> +<Child {...$$restProps} /> +``` + +### Prop Types with JSDoc + +```svelte +<script> + /** + * @type {string} + */ + export let name; + + /** + * @type {'primary' | 'secondary'} + */ + export let variant = 'primary'; + + /** + * @type {(event: CustomEvent) => void} + */ + export let onSelect; +</script> +``` + +## Events + +### DOM Events + +```svelte +<script> + function handleClick(event) { + console.log('clicked', event.target); + } +</script> + +<!-- Basic event --> +<button on:click={handleClick}>Click me</button> + +<!-- Inline handler --> +<button on:click={() => console.log('clicked')}>Click</button> + +<!-- Event modifiers --> +<button on:click|preventDefault={handleClick}>Submit</button> +<button on:click|stopPropagation|once={handleClick}>Once</button> + +<!-- Available modifiers --> +<!-- preventDefault, stopPropagation, passive, nonpassive, capture, once, self, trusted --> +``` + +### Component Events + +Dispatch custom events from components: + +```svelte +<!-- Child.svelte --> +<script> + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher(); + + function handleSelect(item) { + dispatch('select', { item }); + } +</script> + +<button on:click={() => handleSelect('foo')}> + Select +</button> +``` + +```svelte +<!-- Parent.svelte --> +<script> + function handleSelect(event) { + console.log('selected:', event.detail.item); + } +</script> + +<Child on:select={handleSelect} /> +``` + +### Event Forwarding + +```svelte +<!-- Forward all events of a type --> +<button on:click>Click me</button> + +<!-- The parent can now listen --> +<Child on:click={handleClick} /> +``` + +## Bindings + +### Two-Way Binding + +```svelte +<script> + let name = ''; + let agreed = false; + let selected = 'a'; + let quantity = 1; +</script> + +<!-- Text input --> +<input bind:value={name} /> + +<!-- Checkbox --> +<input type="checkbox" bind:checked={agreed} /> + +<!-- Radio buttons --> +<input type="radio" bind:group={selected} value="a" /> A +<input type="radio" bind:group={selected} value="b" /> B + +<!-- Number input --> +<input type="number" bind:value={quantity} /> + +<!-- Select --> +<select bind:value={selected}> + <option value="a">A</option> + <option value="b">B</option> +</select> + +<!-- Textarea --> +<textarea bind:value={content}></textarea> +``` + +### Component Bindings + +```svelte +<!-- Bind to component props --> +<Child bind:value={parentValue} /> + +<!-- Bind to component instance --> +<Child bind:this={childComponent} /> +``` + +### Element Bindings + +```svelte +<script> + let inputElement; + let divWidth; + let divHeight; +</script> + +<!-- DOM element reference --> +<input bind:this={inputElement} /> + +<!-- Dimension bindings (read-only) --> +<div bind:clientWidth={divWidth} bind:clientHeight={divHeight}> + {divWidth} x {divHeight} +</div> +``` + +## Stores + +### Writable Stores + +```javascript +// stores.js +import { writable } from 'svelte/store'; + +export const count = writable(0); + +// With custom methods +function createCounter() { + const { subscribe, set, update } = writable(0); + + return { + subscribe, + increment: () => update(n => n + 1), + decrement: () => update(n => n - 1), + reset: () => set(0) + }; +} + +export const counter = createCounter(); +``` + +```svelte +<script> + import { count, counter } from './stores.js'; + + // Manual subscription + let countValue; + const unsubscribe = count.subscribe(value => { + countValue = value; + }); + + // Auto-subscription with $ prefix (recommended) + // Automatically subscribes and unsubscribes +</script> + +<p>Count: {$count}</p> +<button on:click={() => $count += 1}>Increment</button> + +<p>Counter: {$counter}</p> +<button on:click={counter.increment}>Increment</button> +``` + +### Readable Stores + +```javascript +import { readable } from 'svelte/store'; + +// Time store that updates every second +export const time = readable(new Date(), function start(set) { + const interval = setInterval(() => { + set(new Date()); + }, 1000); + + return function stop() { + clearInterval(interval); + }; +}); +``` + +### Derived Stores + +```javascript +import { derived } from 'svelte/store'; +import { time } from './stores.js'; + +export const elapsed = derived( + time, + $time => Math.round(($time - start) / 1000) +); + +// Derived from multiple stores +export const combined = derived( + [storeA, storeB], + ([$a, $b]) => $a + $b +); + +// Async derived +export const asyncDerived = derived( + source, + ($source, set) => { + fetch(`/api/${$source}`) + .then(r => r.json()) + .then(set); + }, + 'loading...' // initial value +); +``` + +### Store Contract + +Any object with a `subscribe` method is a store: + +```javascript +// Custom store implementation +function createCustomStore(initial) { + let value = initial; + const subscribers = new Set(); + + return { + subscribe(fn) { + subscribers.add(fn); + fn(value); + return () => subscribers.delete(fn); + }, + set(newValue) { + value = newValue; + subscribers.forEach(fn => fn(value)); + } + }; +} +``` + +## Lifecycle + +### Lifecycle Functions + +```svelte +<script> + import { onMount, onDestroy, beforeUpdate, afterUpdate, tick } from 'svelte'; + + // Called when component is mounted to DOM + onMount(() => { + console.log('mounted'); + + // Return cleanup function (like onDestroy) + return () => { + console.log('cleanup on unmount'); + }; + }); + + // Called before component is destroyed + onDestroy(() => { + console.log('destroying'); + }); + + // Called before DOM updates + beforeUpdate(() => { + console.log('about to update'); + }); + + // Called after DOM updates + afterUpdate(() => { + console.log('updated'); + }); + + // Wait for next DOM update + async function handleClick() { + count += 1; + await tick(); + // DOM is now updated + } +</script> +``` + +**Key Points:** +- `onMount` runs only in browser, not during SSR +- `onMount` callbacks must be called during component initialization +- Use `tick()` to wait for pending state changes to apply to DOM + +## Logic Blocks + +### If Blocks + +```svelte +{#if condition} + <p>Condition is true</p> +{:else if otherCondition} + <p>Other condition is true</p> +{:else} + <p>Neither condition is true</p> +{/if} +``` + +### Each Blocks + +```svelte +{#each items as item} + <li>{item.name}</li> +{/each} + +<!-- With index --> +{#each items as item, index} + <li>{index}: {item.name}</li> +{/each} + +<!-- With key for animations/reordering --> +{#each items as item (item.id)} + <li>{item.name}</li> +{/each} + +<!-- Destructuring --> +{#each items as { id, name }} + <li>{id}: {name}</li> +{/each} + +<!-- Empty state --> +{#each items as item} + <li>{item.name}</li> +{:else} + <p>No items</p> +{/each} +``` + +### Await Blocks + +```svelte +{#await promise} + <p>Loading...</p> +{:then value} + <p>The value is {value}</p> +{:catch error} + <p>Error: {error.message}</p> +{/await} + +<!-- Short form (no loading state) --> +{#await promise then value} + <p>The value is {value}</p> +{/await} +``` + +### Key Blocks + +Force component recreation when value changes: + +```svelte +{#key value} + <Component /> +{/key} +``` + +## Slots + +### Basic Slots + +```svelte +<!-- Card.svelte --> +<div class="card"> + <slot> + <!-- Fallback content --> + <p>No content provided</p> + </slot> +</div> +``` + +```svelte +<Card> + <p>Card content</p> +</Card> +``` + +### Named Slots + +```svelte +<!-- Layout.svelte --> +<div class="layout"> + <header> + <slot name="header"></slot> + </header> + <main> + <slot></slot> + </main> + <footer> + <slot name="footer"></slot> + </footer> +</div> +``` + +```svelte +<Layout> + <h1 slot="header">Page Title</h1> + <p>Main content</p> + <p slot="footer">Footer content</p> +</Layout> +``` + +### Slot Props + +```svelte +<!-- List.svelte --> +<ul> + {#each items as item} + <li> + <slot {item} index={items.indexOf(item)}> + {item.name} + </slot> + </li> + {/each} +</ul> +``` + +```svelte +<List {items} let:item let:index> + <span>{index}: {item.name}</span> +</List> +``` + +## Transitions and Animations + +### Transitions + +```svelte +<script> + import { fade, fly, slide, scale, blur, draw } from 'svelte/transition'; + import { quintOut } from 'svelte/easing'; + + let visible = true; +</script> + +<!-- Basic transition --> +{#if visible} + <div transition:fade>Fades in and out</div> +{/if} + +<!-- With parameters --> +{#if visible} + <div transition:fly={{ y: 200, duration: 300 }}> + Flies in + </div> +{/if} + +<!-- Separate in/out transitions --> +{#if visible} + <div in:fly={{ y: 200 }} out:fade> + Different transitions + </div> +{/if} + +<!-- With easing --> +{#if visible} + <div transition:slide={{ duration: 300, easing: quintOut }}> + Slides with easing + </div> +{/if} +``` + +### Custom Transitions + +```javascript +function typewriter(node, { speed = 1 }) { + const valid = node.childNodes.length === 1 + && node.childNodes[0].nodeType === Node.TEXT_NODE; + + if (!valid) { + throw new Error('This transition only works on text nodes'); + } + + const text = node.textContent; + const duration = text.length / (speed * 0.01); + + return { + duration, + tick: t => { + const i = Math.trunc(text.length * t); + node.textContent = text.slice(0, i); + } + }; +} +``` + +### Animations + +Animate elements when they move within an each block: + +```svelte +<script> + import { flip } from 'svelte/animate'; +</script> + +{#each items as item (item.id)} + <li animate:flip={{ duration: 300 }}> + {item.name} + </li> +{/each} +``` + +## Actions + +Reusable element-level logic: + +```svelte +<script> + function clickOutside(node, callback) { + const handleClick = event => { + if (!node.contains(event.target)) { + callback(); + } + }; + + document.addEventListener('click', handleClick, true); + + return { + destroy() { + document.removeEventListener('click', handleClick, true); + } + }; + } + + function tooltip(node, text) { + // Setup tooltip + + return { + update(newText) { + // Update when text changes + }, + destroy() { + // Cleanup + } + }; + } +</script> + +<div use:clickOutside={() => visible = false}> + Click outside to close +</div> + +<button use:tooltip={'Click me!'}> + Hover for tooltip +</button> +``` + +## Special Elements + +### svelte:component + +Dynamic component rendering: + +```svelte +<script> + import Red from './Red.svelte'; + import Blue from './Blue.svelte'; + + let selected = Red; +</script> + +<svelte:component this={selected} /> +``` + +### svelte:element + +Dynamic HTML elements: + +```svelte +<script> + let tag = 'h1'; +</script> + +<svelte:element this={tag}>Dynamic heading</svelte:element> +``` + +### svelte:window + +```svelte +<script> + let innerWidth; + let innerHeight; + + function handleKeydown(event) { + console.log(event.key); + } +</script> + +<svelte:window + bind:innerWidth + bind:innerHeight + on:keydown={handleKeydown} +/> +``` + +### svelte:body and svelte:head + +```svelte +<svelte:body on:mouseenter={handleMouseenter} /> + +<svelte:head> + <title>Page Title</title> + <meta name="description" content="..." /> +</svelte:head> +``` + +### svelte:options + +```svelte +<svelte:options + immutable={true} + accessors={true} + namespace="svg" +/> +``` + +## Context API + +Share data between components without prop drilling: + +```svelte +<!-- Parent.svelte --> +<script> + import { setContext } from 'svelte'; + + setContext('theme', { + color: 'dark', + toggle: () => { /* ... */ } + }); +</script> +``` + +```svelte +<!-- Deeply nested child --> +<script> + import { getContext } from 'svelte'; + + const theme = getContext('theme'); +</script> + +<p>Current theme: {theme.color}</p> +``` + +**Key Points:** +- Context is not reactive by default +- Use stores in context for reactive values +- Context is available only during component initialization + +## Best Practices + +### Component Design + +1. **Keep components focused** - Single responsibility +2. **Use composition** - Prefer slots over complex props +3. **Extract logic to stores** - Shared state in stores +4. **Use actions for DOM logic** - Reusable element behaviors +5. **Type with JSDoc** - Document prop types + +### Reactivity + +1. **Understand triggers** - Assignments trigger updates +2. **Use immutable patterns** - Spread for arrays/objects +3. **Avoid side effects in reactive statements** - Keep them pure +4. **Use derived stores** - For computed values from stores + +### Performance + +1. **Key each blocks** - Use unique keys for list items +2. **Use immutable option** - When data is immutable +3. **Lazy load components** - Dynamic imports +4. **Minimize store subscriptions** - Unsubscribe when done + +### State Management + +1. **Local state first** - Component variables for local state +2. **Stores for shared state** - Cross-component communication +3. **Context for configuration** - Theme, i18n, etc. +4. **Custom stores for logic** - Encapsulate complex state + +## Common Patterns + +### Async Data Loading + +```svelte +<script> + import { onMount } from 'svelte'; + + let data = null; + let loading = true; + let error = null; + + onMount(async () => { + try { + const response = await fetch('/api/data'); + data = await response.json(); + } catch (e) { + error = e; + } finally { + loading = false; + } + }); +</script> + +{#if loading} + <p>Loading...</p> +{:else if error} + <p>Error: {error.message}</p> +{:else} + <p>{data}</p> +{/if} +``` + +### Form Handling + +```svelte +<script> + let formData = { + name: '', + email: '' + }; + let errors = {}; + let submitting = false; + + function validate() { + errors = {}; + if (!formData.name) errors.name = 'Name is required'; + if (!formData.email) errors.email = 'Email is required'; + return Object.keys(errors).length === 0; + } + + async function handleSubmit() { + if (!validate()) return; + + submitting = true; + try { + await fetch('/api/submit', { + method: 'POST', + body: JSON.stringify(formData) + }); + } finally { + submitting = false; + } + } +</script> + +<form on:submit|preventDefault={handleSubmit}> + <input bind:value={formData.name} /> + {#if errors.name}<span class="error">{errors.name}</span>{/if} + + <input bind:value={formData.email} type="email" /> + {#if errors.email}<span class="error">{errors.email}</span>{/if} + + <button disabled={submitting}> + {submitting ? 'Submitting...' : 'Submit'} + </button> +</form> +``` + +### Modal Pattern + +```svelte +<!-- Modal.svelte --> +<script> + export let open = false; + + function close() { + open = false; + } +</script> + +{#if open} + <div class="backdrop" on:click={close}> + <div class="modal" on:click|stopPropagation> + <slot /> + <button on:click={close}>Close</button> + </div> + </div> +{/if} +``` + +## Troubleshooting + +### Common Issues + +**Reactivity not working:** +- Check for proper assignment (reassign arrays/objects) +- Use `$:` for derived values +- Store subscriptions need `$` prefix + +**Component not updating:** +- Verify prop changes trigger parent re-render +- Check key blocks for forced recreation +- Use `{#key}` to force component recreation + +**Memory leaks:** +- Clean up subscriptions in `onDestroy` +- Return cleanup functions from `onMount` +- Unsubscribe from stores manually if not using `$` + +**Styles not applying:** +- Check for `:global()` if targeting child components +- Verify CSS specificity +- Use `class:` directive properly + +## References + +- **Svelte Documentation**: https://svelte.dev/docs +- **Svelte Tutorial**: https://svelte.dev/tutorial +- **Svelte REPL**: https://svelte.dev/repl +- **Svelte Society**: https://sveltesociety.dev +- **GitHub**: https://github.com/sveltejs/svelte + +## Related Skills + +- **rollup** - Bundling Svelte applications +- **nostr-tools** - Nostr integration in Svelte apps +- **typescript** - TypeScript with Svelte diff --git a/.claude/skills/typescript/README.md b/.claude/skills/typescript/README.md new file mode 100644 index 00000000..84093f7b --- /dev/null +++ b/.claude/skills/typescript/README.md @@ -0,0 +1,133 @@ +# TypeScript Claude Skill + +Comprehensive TypeScript skill for type-safe development with modern JavaScript/TypeScript applications. + +## Overview + +This skill provides in-depth knowledge about TypeScript's type system, patterns, best practices, and integration with popular frameworks like React. It covers everything from basic types to advanced type manipulation techniques. + +## Files + +### Core Documentation +- **SKILL.md** - Main skill file with workflows and when to use this skill +- **quick-reference.md** - Quick lookup guide for common TypeScript syntax and patterns + +### Reference Materials +- **references/type-system.md** - Comprehensive guide to TypeScript's type system +- **references/utility-types.md** - Complete reference for built-in and custom utility types +- **references/common-patterns.md** - Real-world TypeScript patterns and idioms + +### Examples +- **examples/type-system-basics.ts** - Fundamental TypeScript concepts +- **examples/advanced-types.ts** - Generics, conditional types, mapped types +- **examples/react-patterns.ts** - Type-safe React components and hooks +- **examples/README.md** - Guide to using the examples + +## Usage + +### When to Use This Skill + +Reference this skill when: +- Writing or refactoring TypeScript code +- Designing type-safe APIs and interfaces +- Working with advanced type system features +- Configuring TypeScript projects +- Troubleshooting type errors +- Implementing type-safe patterns with libraries +- Converting JavaScript to TypeScript + +### Quick Start + +For quick lookups, start with `quick-reference.md` which provides concise syntax and patterns. + +For learning or deep dives: +1. **Fundamentals**: Start with `references/type-system.md` +2. **Utilities**: Learn about transformations in `references/utility-types.md` +3. **Patterns**: Study real-world patterns in `references/common-patterns.md` +4. **Practice**: Explore code examples in `examples/` + +## Key Topics Covered + +### Type System +- Primitive types and special types +- Object types (interfaces, type aliases) +- Union and intersection types +- Literal types and template literal types +- Type inference and narrowing +- Generic types with constraints +- Conditional types and mapped types +- Recursive types + +### Advanced Features +- Type guards and type predicates +- Assertion functions +- Branded types for nominal typing +- Key remapping and filtering +- Distributive conditional types +- Type-level programming + +### Utility Types +- Built-in utilities (Partial, Pick, Omit, etc.) +- Custom utility type patterns +- Deep transformations +- Type composition + +### React Integration +- Component props typing +- Generic components +- Hooks with TypeScript +- Context with type safety +- Event handlers +- Ref typing + +### Best Practices +- Type safety patterns +- Error handling +- Code organization +- Integration with Zod for runtime validation +- Named return variables (Go-style) +- Discriminated unions for state management + +## Integration with Project Stack + +This skill is designed to work seamlessly with: +- **React 19**: Type-safe component development +- **TanStack Ecosystem**: Typed queries, routing, forms, and stores +- **Zod**: Runtime validation with type inference +- **Radix UI**: Component prop typing +- **Tailwind CSS**: Type-safe className composition + +## Examples + +All examples are self-contained and demonstrate practical patterns: +- Based on real-world usage +- Follow project best practices +- Include comprehensive comments +- Can be run with `ts-node` +- Ready to adapt to your needs + +## Configuration + +The skill includes guidance on TypeScript configuration with recommended settings for: +- Strict type checking +- Module resolution +- JSX support +- Path aliases +- Declaration files + +## Contributing + +When adding new patterns or examples: +1. Follow existing file structure +2. Include comprehensive comments +3. Demonstrate real-world usage +4. Add to appropriate reference file +5. Update this README if needed + +## Resources + +- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/) +- [TypeScript Deep Dive](https://basarat.gitbook.io/typescript/) +- [Type Challenges](https://github.com/type-challenges/type-challenges) +- [TSConfig Reference](https://www.typescriptlang.org/tsconfig) + diff --git a/.claude/skills/typescript/SKILL.md b/.claude/skills/typescript/SKILL.md new file mode 100644 index 00000000..ebe59377 --- /dev/null +++ b/.claude/skills/typescript/SKILL.md @@ -0,0 +1,359 @@ +--- +name: typescript +description: This skill should be used when working with TypeScript code, including type definitions, type inference, generics, utility types, and TypeScript configuration. Provides comprehensive knowledge of TypeScript patterns, best practices, and advanced type system features. +--- + +# TypeScript Skill + +This skill provides comprehensive knowledge and patterns for working with TypeScript effectively in modern applications. + +## When to Use This Skill + +Use this skill when: +- Writing or refactoring TypeScript code +- Designing type-safe APIs and interfaces +- Working with advanced type system features (generics, conditional types, mapped types) +- Configuring TypeScript projects (tsconfig.json) +- Troubleshooting type errors +- Implementing type-safe patterns with libraries (React, TanStack, etc.) +- Converting JavaScript code to TypeScript + +## Core Concepts + +### Type System Fundamentals + +TypeScript provides static typing for JavaScript with a powerful type system that includes: +- Primitive types (string, number, boolean, null, undefined, symbol, bigint) +- Object types (interfaces, type aliases, classes) +- Array and tuple types +- Union and intersection types +- Literal types and template literal types +- Type inference and type narrowing +- Generic types with constraints +- Conditional types and mapped types + +### Type Inference + +Leverage TypeScript's type inference to write less verbose code: +- Let TypeScript infer return types when obvious +- Use type inference for variable declarations +- Rely on generic type inference in function calls +- Use `as const` for immutable literal types + +### Type Safety Patterns + +Implement type-safe patterns: +- Use discriminated unions for state management +- Implement type guards for runtime type checking +- Use branded types for nominal typing +- Leverage conditional types for API design +- Use template literal types for string manipulation + +## Key Workflows + +### 1. Designing Type-Safe APIs + +When designing APIs, follow these patterns: + +**Interface vs Type Alias:** +- Use `interface` for object shapes that may be extended +- Use `type` for unions, intersections, and complex type operations +- Use `type` with mapped types and conditional types + +**Generic Constraints:** +```typescript +// Use extends for generic constraints +function getValue<T extends { id: string }>(item: T): string { + return item.id +} +``` + +**Discriminated Unions:** +```typescript +// Use for type-safe state machines +type State = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; data: Data } + | { status: 'error'; error: Error } +``` + +### 2. Working with Utility Types + +Use built-in utility types for common transformations: +- `Partial<T>` - Make all properties optional +- `Required<T>` - Make all properties required +- `Readonly<T>` - Make all properties readonly +- `Pick<T, K>` - Select specific properties +- `Omit<T, K>` - Exclude specific properties +- `Record<K, T>` - Create object type with specific keys +- `Exclude<T, U>` - Exclude types from union +- `Extract<T, U>` - Extract types from union +- `NonNullable<T>` - Remove null/undefined +- `ReturnType<T>` - Get function return type +- `Parameters<T>` - Get function parameter types +- `Awaited<T>` - Unwrap Promise type + +### 3. Advanced Type Patterns + +**Mapped Types:** +```typescript +// Transform object types +type Nullable<T> = { + [K in keyof T]: T[K] | null +} + +type ReadonlyDeep<T> = { + readonly [K in keyof T]: T[K] extends object + ? ReadonlyDeep<T[K]> + : T[K] +} +``` + +**Conditional Types:** +```typescript +// Type-level logic +type IsArray<T> = T extends Array<any> ? true : false + +type Flatten<T> = T extends Array<infer U> ? U : T +``` + +**Template Literal Types:** +```typescript +// String manipulation at type level +type EventName<T extends string> = `on${Capitalize<T>}` +type Route = `/api/${'users' | 'posts'}/${string}` +``` + +### 4. Type Narrowing + +Use type guards and narrowing techniques: + +**typeof guards:** +```typescript +if (typeof value === 'string') { + // value is string here +} +``` + +**instanceof guards:** +```typescript +if (error instanceof Error) { + // error is Error here +} +``` + +**Custom type guards:** +```typescript +function isUser(value: unknown): value is User { + return typeof value === 'object' && value !== null && 'id' in value +} +``` + +**Discriminated unions:** +```typescript +function handle(state: State) { + switch (state.status) { + case 'idle': + // state is { status: 'idle' } + break + case 'success': + // state is { status: 'success'; data: Data } + console.log(state.data) + break + } +} +``` + +### 5. Working with External Libraries + +**Typing Third-Party Libraries:** +- Install type definitions: `npm install --save-dev @types/package-name` +- Create custom declarations in `.d.ts` files when types unavailable +- Use module augmentation to extend existing type definitions + +**Declaration Files:** +```typescript +// globals.d.ts +declare global { + interface Window { + myCustomProperty: string + } +} + +export {} +``` + +### 6. TypeScript Configuration + +Configure `tsconfig.json` for strict type checking: + +**Essential Strict Options:** +```json +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + } +} +``` + +## Best Practices + +### 1. Prefer Type Inference Over Explicit Types +Let TypeScript infer types when they're obvious from context. + +### 2. Use Strict Mode +Enable strict type checking to catch more errors at compile time. + +### 3. Avoid `any` Type +Use `unknown` for truly unknown types, then narrow with type guards. + +### 4. Use Const Assertions +Use `as const` for immutable values and narrow literal types. + +### 5. Leverage Discriminated Unions +Use for state machines and variant types for better type safety. + +### 6. Create Reusable Generic Types +Extract common type patterns into reusable generics. + +### 7. Use Branded Types for Nominal Typing +Create distinct types for values with same structure but different meaning. + +### 8. Document Complex Types +Add JSDoc comments to explain non-obvious type decisions. + +### 9. Use Type-Only Imports +Use `import type` for type-only imports to aid tree-shaking. + +### 10. Handle Errors with Type Guards +Use type guards to safely work with error objects. + +## Common Patterns + +### React Component Props +```typescript +// Use interface for component props +interface ButtonProps { + variant?: 'primary' | 'secondary' + size?: 'sm' | 'md' | 'lg' + onClick?: () => void + children: React.ReactNode +} + +export function Button({ variant = 'primary', size = 'md', onClick, children }: ButtonProps) { + // implementation +} +``` + +### API Response Types +```typescript +// Use discriminated unions for API responses +type ApiResponse<T> = + | { success: true; data: T } + | { success: false; error: string } + +// Helper for safe API calls +async function fetchData<T>(url: string): Promise<ApiResponse<T>> { + try { + const response = await fetch(url) + const data = await response.json() + return { success: true, data } + } catch (error) { + return { success: false, error: String(error) } + } +} +``` + +### Store/State Types +```typescript +// Use interfaces for state objects +interface AppState { + user: User | null + isAuthenticated: boolean + theme: 'light' | 'dark' +} + +// Use type for actions (discriminated union) +type AppAction = + | { type: 'LOGIN'; payload: User } + | { type: 'LOGOUT' } + | { type: 'SET_THEME'; payload: 'light' | 'dark' } +``` + +## References + +For detailed information on specific topics, refer to: +- `references/type-system.md` - Deep dive into TypeScript's type system +- `references/utility-types.md` - Complete guide to built-in utility types +- `references/advanced-types.md` - Advanced type patterns and techniques +- `references/tsconfig-reference.md` - Comprehensive tsconfig.json reference +- `references/common-patterns.md` - Common TypeScript patterns and idioms +- `examples/` - Practical code examples + +## Troubleshooting + +### Common Type Errors + +**Type 'X' is not assignable to type 'Y':** +- Check if types are compatible +- Use type assertions when you know better than the compiler +- Consider using union types or widening the target type + +**Object is possibly 'null' or 'undefined':** +- Use optional chaining: `object?.property` +- Use nullish coalescing: `value ?? defaultValue` +- Add type guards or null checks + +**Type 'any' implicitly has...** +- Enable strict mode and fix type definitions +- Add explicit type annotations +- Use `unknown` instead of `any` when appropriate + +**Cannot find module or its type declarations:** +- Install type definitions: `@types/package-name` +- Create custom `.d.ts` declaration file +- Add to `types` array in tsconfig.json + +## Integration with Project Stack + +### React 19 +Use TypeScript with React 19 features: +- Type component props with interfaces +- Use generic types for hooks +- Type context providers properly +- Use `React.FC` sparingly (prefer explicit typing) + +### TanStack Ecosystem +Type TanStack libraries properly: +- TanStack Query: Type query keys and data +- TanStack Router: Use typed route definitions +- TanStack Form: Type form values and validation +- TanStack Store: Type state and actions + +### Zod Integration +Combine Zod with TypeScript: +- Use `z.infer<typeof schema>` to extract types from schemas +- Let Zod handle runtime validation +- Use TypeScript for compile-time type checking + +## Resources + +The TypeScript documentation provides comprehensive information: +- Handbook: https://www.typescriptlang.org/docs/handbook/ +- Type manipulation: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html +- Utility types: https://www.typescriptlang.org/docs/handbook/utility-types.html +- TSConfig reference: https://www.typescriptlang.org/tsconfig + diff --git a/.claude/skills/typescript/examples/README.md b/.claude/skills/typescript/examples/README.md new file mode 100644 index 00000000..4a19a0a5 --- /dev/null +++ b/.claude/skills/typescript/examples/README.md @@ -0,0 +1,45 @@ +# TypeScript Examples + +This directory contains practical TypeScript examples demonstrating various patterns and features. + +## Examples + +1. **type-system-basics.ts** - Fundamental TypeScript types and features +2. **advanced-types.ts** - Generics, conditional types, and mapped types +3. **react-patterns.ts** - Type-safe React components and hooks +4. **api-patterns.ts** - API response handling with type safety +5. **validation.ts** - Runtime validation with Zod and TypeScript + +## How to Use + +Each example file is self-contained and demonstrates specific TypeScript concepts. They're based on real-world patterns used in the Plebeian Market application and follow best practices for: + +- Type safety +- Error handling +- Code organization +- Reusability +- Maintainability + +## Running Examples + +These examples are TypeScript files that can be: +- Copied into your project +- Used as reference for patterns +- Modified for your specific needs +- Run with `ts-node` for testing + +```bash +# Run an example +npx ts-node examples/type-system-basics.ts +``` + +## Learning Path + +1. Start with `type-system-basics.ts` to understand fundamentals +2. Move to `advanced-types.ts` for complex type patterns +3. Explore `react-patterns.ts` for component typing +4. Study `api-patterns.ts` for type-safe API handling +5. Review `validation.ts` for runtime safety + +Each example builds on previous concepts, so following this order is recommended for learners. + diff --git a/.claude/skills/typescript/examples/advanced-types.ts b/.claude/skills/typescript/examples/advanced-types.ts new file mode 100644 index 00000000..0a00ac60 --- /dev/null +++ b/.claude/skills/typescript/examples/advanced-types.ts @@ -0,0 +1,478 @@ +/** + * Advanced TypeScript Types + * + * This file demonstrates advanced TypeScript features including: + * - Generics with constraints + * - Conditional types + * - Mapped types + * - Template literal types + * - Recursive types + * - Utility type implementations + */ + +// ============================================================================ +// Generics Basics +// ============================================================================ + +// Generic function +function identity<T>(value: T): T { + return value +} + +const stringValue = identity('hello') // Type: string +const numberValue = identity(42) // Type: number + +// Generic interface +interface Box<T> { + value: T +} + +const stringBox: Box<string> = { value: 'hello' } +const numberBox: Box<number> = { value: 42 } + +// Generic class +class Stack<T> { + private items: T[] = [] + + push(item: T): void { + this.items.push(item) + } + + pop(): T | undefined { + return this.items.pop() + } + + peek(): T | undefined { + return this.items[this.items.length - 1] + } + + isEmpty(): boolean { + return this.items.length === 0 + } +} + +const numberStack = new Stack<number>() +numberStack.push(1) +numberStack.push(2) +numberStack.pop() // Type: number | undefined + +// ============================================================================ +// Generic Constraints +// ============================================================================ + +// Constrain to specific type +interface HasLength { + length: number +} + +function logLength<T extends HasLength>(item: T): void { + console.log(item.length) +} + +logLength('string') // OK +logLength([1, 2, 3]) // OK +logLength({ length: 10 }) // OK +// logLength(42) // Error: number doesn't have length + +// Constrain to object keys +function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { + return obj[key] +} + +interface User { + id: string + name: string + age: number +} + +const user: User = { id: '1', name: 'Alice', age: 30 } +const userName = getProperty(user, 'name') // Type: string +// const invalid = getProperty(user, 'invalid') // Error + +// Multiple type parameters with constraints +function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U { + return { ...obj1, ...obj2 } +} + +const merged = merge({ a: 1 }, { b: 2 }) // Type: { a: number } & { b: number } + +// ============================================================================ +// Conditional Types +// ============================================================================ + +// Basic conditional type +type IsString<T> = T extends string ? true : false + +type A = IsString<string> // true +type B = IsString<number> // false + +// Nested conditional types +type TypeName<T> = T extends string + ? 'string' + : T extends number + ? 'number' + : T extends boolean + ? 'boolean' + : T extends undefined + ? 'undefined' + : T extends Function + ? 'function' + : 'object' + +type T1 = TypeName<string> // "string" +type T2 = TypeName<number> // "number" +type T3 = TypeName<() => void> // "function" + +// Distributive conditional types +type ToArray<T> = T extends any ? T[] : never + +type StrArrOrNumArr = ToArray<string | number> // string[] | number[] + +// infer keyword +type Flatten<T> = T extends Array<infer U> ? U : T + +type Str = Flatten<string[]> // string +type Num = Flatten<number> // number + +// Return type extraction +type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never + +function exampleFn(): string { + return 'hello' +} + +type ExampleReturn = MyReturnType<typeof exampleFn> // string + +// Parameters extraction +type MyParameters<T> = T extends (...args: infer P) => any ? P : never + +function createUser(name: string, age: number): User { + return { id: '1', name, age } +} + +type CreateUserParams = MyParameters<typeof createUser> // [string, number] + +// ============================================================================ +// Mapped Types +// ============================================================================ + +// Make all properties optional +type MyPartial<T> = { + [K in keyof T]?: T[K] +} + +interface Person { + name: string + age: number + email: string +} + +type PartialPerson = MyPartial<Person> +// { +// name?: string +// age?: number +// email?: string +// } + +// Make all properties required +type MyRequired<T> = { + [K in keyof T]-?: T[K] +} + +// Make all properties readonly +type MyReadonly<T> = { + readonly [K in keyof T]: T[K] +} + +// Pick specific properties +type MyPick<T, K extends keyof T> = { + [P in K]: T[P] +} + +type UserProfile = MyPick<User, 'id' | 'name'> +// { id: string; name: string } + +// Omit specific properties +type MyOmit<T, K extends keyof T> = { + [P in keyof T as P extends K ? never : P]: T[P] +} + +type UserWithoutAge = MyOmit<User, 'age'> +// { id: string; name: string } + +// Transform property types +type Nullable<T> = { + [K in keyof T]: T[K] | null +} + +type NullablePerson = Nullable<Person> +// { +// name: string | null +// age: number | null +// email: string | null +// } + +// ============================================================================ +// Key Remapping +// ============================================================================ + +// Add prefix to keys +type Getters<T> = { + [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] +} + +type PersonGetters = Getters<Person> +// { +// getName: () => string +// getAge: () => number +// getEmail: () => string +// } + +// Filter keys by type +type PickByType<T, U> = { + [K in keyof T as T[K] extends U ? K : never]: T[K] +} + +interface Model { + id: number + name: string + description: string + price: number +} + +type StringFields = PickByType<Model, string> +// { name: string; description: string } + +// Remove specific key +type RemoveKindField<T> = { + [K in keyof T as Exclude<K, 'kind'>]: T[K] +} + +// ============================================================================ +// Template Literal Types +// ============================================================================ + +// Event name generation +type EventName<T extends string> = `on${Capitalize<T>}` + +type ClickEvent = EventName<'click'> // "onClick" +type SubmitEvent = EventName<'submit'> // "onSubmit" + +// Combining literals +type Color = 'red' | 'green' | 'blue' +type Shade = 'light' | 'dark' +type ColorShade = `${Shade}-${Color}` +// "light-red" | "light-green" | "light-blue" | "dark-red" | "dark-green" | "dark-blue" + +// CSS properties +type CSSProperty = 'margin' | 'padding' +type Side = 'top' | 'right' | 'bottom' | 'left' +type CSSPropertyWithSide = `${CSSProperty}-${Side}` +// "margin-top" | "margin-right" | ... | "padding-left" + +// Route generation +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' +type Endpoint = '/users' | '/products' | '/orders' +type ApiRoute = `${HttpMethod} ${Endpoint}` +// "GET /users" | "POST /users" | ... | "DELETE /orders" + +// ============================================================================ +// Recursive Types +// ============================================================================ + +// JSON value type +type JSONValue = string | number | boolean | null | JSONObject | JSONArray + +interface JSONObject { + [key: string]: JSONValue +} + +interface JSONArray extends Array<JSONValue> {} + +// Tree structure +interface TreeNode<T> { + value: T + children?: TreeNode<T>[] +} + +const tree: TreeNode<number> = { + value: 1, + children: [ + { value: 2, children: [{ value: 4 }, { value: 5 }] }, + { value: 3, children: [{ value: 6 }] }, + ], +} + +// Deep readonly +type DeepReadonly<T> = { + readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] +} + +interface NestedConfig { + api: { + url: string + timeout: number + } + features: { + darkMode: boolean + } +} + +type ImmutableConfig = DeepReadonly<NestedConfig> +// All properties at all levels are readonly + +// Deep partial +type DeepPartial<T> = { + [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] +} + +// ============================================================================ +// Advanced Utility Types +// ============================================================================ + +// Exclude types from union +type MyExclude<T, U> = T extends U ? never : T + +type T4 = MyExclude<'a' | 'b' | 'c', 'a'> // "b" | "c" + +// Extract types from union +type MyExtract<T, U> = T extends U ? T : never + +type T5 = MyExtract<'a' | 'b' | 'c', 'a' | 'f'> // "a" + +// NonNullable +type MyNonNullable<T> = T extends null | undefined ? never : T + +type T6 = MyNonNullable<string | null | undefined> // string + +// Record +type MyRecord<K extends keyof any, T> = { + [P in K]: T +} + +type PageInfo = MyRecord<string, number> + +// Awaited +type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T + +type T7 = MyAwaited<Promise<string>> // string +type T8 = MyAwaited<Promise<Promise<number>>> // number + +// ============================================================================ +// Branded Types +// ============================================================================ + +type Brand<K, T> = K & { __brand: T } + +type USD = Brand<number, 'USD'> +type EUR = Brand<number, 'EUR'> +type UserId = Brand<string, 'UserId'> +type ProductId = Brand<string, 'ProductId'> + +function makeUSD(amount: number): USD { + return amount as USD +} + +function makeUserId(id: string): UserId { + return id as UserId +} + +const usd = makeUSD(100) +const userId = makeUserId('user-123') + +// Type-safe operations +function addMoney(a: USD, b: USD): USD { + return (a + b) as USD +} + +// Prevents mixing different branded types +// const total = addMoney(usd, eur) // Error + +// ============================================================================ +// Union to Intersection +// ============================================================================ + +type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never + +type Union = { a: string } | { b: number } +type Intersection = UnionToIntersection<Union> +// { a: string } & { b: number } + +// ============================================================================ +// Advanced Generic Patterns +// ============================================================================ + +// Constraining multiple related types +function merge< + T extends Record<string, any>, + U extends Record<string, any>, + K extends keyof T & keyof U, +>(obj1: T, obj2: U, conflictKeys: K[]): T & U { + const result = { ...obj1, ...obj2 } + conflictKeys.forEach((key) => { + // Handle conflicts + }) + return result as T & U +} + +// Builder pattern with fluent API +class QueryBuilder<T, Selected extends keyof T = never> { + private selectFields: Set<keyof T> = new Set() + + select<K extends keyof T>( + ...fields: K[] + ): QueryBuilder<T, Selected | K> { + fields.forEach((field) => this.selectFields.add(field)) + return this as any + } + + execute(): Pick<T, Selected> { + // Execute query + return {} as Pick<T, Selected> + } +} + +// Usage +interface Product { + id: string + name: string + price: number + description: string +} + +const result = new QueryBuilder<Product>() + .select('id', 'name') + .select('price') + .execute() +// Type: { id: string; name: string; price: number } + +// ============================================================================ +// Exports +// ============================================================================ + +export type { + Box, + HasLength, + IsString, + Flatten, + MyPartial, + MyRequired, + MyReadonly, + Nullable, + DeepReadonly, + DeepPartial, + Brand, + USD, + EUR, + UserId, + ProductId, + JSONValue, + TreeNode, +} + +export { Stack, identity, getProperty, merge, makeUSD, makeUserId } + diff --git a/.claude/skills/typescript/examples/react-patterns.ts b/.claude/skills/typescript/examples/react-patterns.ts new file mode 100644 index 00000000..a50b6895 --- /dev/null +++ b/.claude/skills/typescript/examples/react-patterns.ts @@ -0,0 +1,555 @@ +/** + * TypeScript React Patterns + * + * This file demonstrates type-safe React patterns including: + * - Component props typing + * - Hooks with TypeScript + * - Context with type safety + * - Generic components + * - Event handlers + * - Ref types + */ + +import { createContext, useContext, useEffect, useReducer, useRef, useState } from 'react' +import type { ReactNode, InputHTMLAttributes, FormEvent, ChangeEvent } from 'react' + +// ============================================================================ +// Component Props Patterns +// ============================================================================ + +// Basic component with props +interface ButtonProps { + variant?: 'primary' | 'secondary' | 'tertiary' + size?: 'sm' | 'md' | 'lg' + disabled?: boolean + onClick?: () => void + children: ReactNode +} + +export function Button({ + variant = 'primary', + size = 'md', + disabled = false, + onClick, + children, +}: ButtonProps) { + return ( + <button + className={`btn-${variant} btn-${size}`} + disabled={disabled} + onClick={onClick} + > + {children} + </button> + ) +} + +// Props extending HTML attributes +interface InputProps extends InputHTMLAttributes<HTMLInputElement> { + label?: string + error?: string + helperText?: string +} + +export function Input({ label, error, helperText, ...inputProps }: InputProps) { + return ( + <div className="input-wrapper"> + {label && <label>{label}</label>} + <input className={error ? 'input-error' : ''} {...inputProps} /> + {error && <span className="error">{error}</span>} + {helperText && <span className="helper">{helperText}</span>} + </div> + ) +} + +// Generic component +interface ListProps<T> { + items: T[] + renderItem: (item: T, index: number) => ReactNode + keyExtractor: (item: T, index: number) => string + emptyMessage?: string +} + +export function List<T>({ + items, + renderItem, + keyExtractor, + emptyMessage = 'No items', +}: ListProps<T>) { + if (items.length === 0) { + return <div>{emptyMessage}</div> + } + + return ( + <ul> + {items.map((item, index) => ( + <li key={keyExtractor(item, index)}>{renderItem(item, index)}</li> + ))} + </ul> + ) +} + +// Component with children render prop +interface ContainerProps { + isLoading: boolean + error: Error | null + children: (props: { retry: () => void }) => ReactNode +} + +export function Container({ isLoading, error, children }: ContainerProps) { + const retry = () => { + // Retry logic + } + + if (isLoading) return <div>Loading...</div> + if (error) return <div>Error: {error.message}</div> + + return <>{children({ retry })}</> +} + +// ============================================================================ +// Hooks Patterns +// ============================================================================ + +// useState with explicit type +function useCounter(initialValue: number = 0) { + const [count, setCount] = useState<number>(initialValue) + + const increment = () => setCount((c) => c + 1) + const decrement = () => setCount((c) => c - 1) + const reset = () => setCount(initialValue) + + return { count, increment, decrement, reset } +} + +// useState with union type +type LoadingState = 'idle' | 'loading' | 'success' | 'error' + +function useLoadingState() { + const [state, setState] = useState<LoadingState>('idle') + + const startLoading = () => setState('loading') + const setSuccess = () => setState('success') + const setError = () => setState('error') + const reset = () => setState('idle') + + return { state, startLoading, setSuccess, setError, reset } +} + +// Custom hook with options +interface UseFetchOptions<T> { + initialData?: T + onSuccess?: (data: T) => void + onError?: (error: Error) => void +} + +interface UseFetchReturn<T> { + data: T | undefined + loading: boolean + error: Error | null + refetch: () => Promise<void> +} + +function useFetch<T>(url: string, options?: UseFetchOptions<T>): UseFetchReturn<T> { + const [data, setData] = useState<T | undefined>(options?.initialData) + const [loading, setLoading] = useState(false) + const [error, setError] = useState<Error | null>(null) + + const fetchData = async () => { + setLoading(true) + setError(null) + + try { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const json = await response.json() + setData(json) + options?.onSuccess?.(json) + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + setError(error) + options?.onError?.(error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, [url]) + + return { data, loading, error, refetch: fetchData } +} + +// useReducer with discriminated unions +interface User { + id: string + name: string + email: string +} + +type FetchState<T> = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; data: T } + | { status: 'error'; error: Error } + +type FetchAction<T> = + | { type: 'FETCH_START' } + | { type: 'FETCH_SUCCESS'; payload: T } + | { type: 'FETCH_ERROR'; error: Error } + | { type: 'RESET' } + +function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> { + switch (action.type) { + case 'FETCH_START': + return { status: 'loading' } + case 'FETCH_SUCCESS': + return { status: 'success', data: action.payload } + case 'FETCH_ERROR': + return { status: 'error', error: action.error } + case 'RESET': + return { status: 'idle' } + } +} + +function useFetchWithReducer<T>(url: string) { + const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' }) + + useEffect(() => { + let isCancelled = false + + const fetchData = async () => { + dispatch({ type: 'FETCH_START' }) + + try { + const response = await fetch(url) + const data = await response.json() + + if (!isCancelled) { + dispatch({ type: 'FETCH_SUCCESS', payload: data }) + } + } catch (error) { + if (!isCancelled) { + dispatch({ + type: 'FETCH_ERROR', + error: error instanceof Error ? error : new Error(String(error)), + }) + } + } + } + + fetchData() + + return () => { + isCancelled = true + } + }, [url]) + + return state +} + +// ============================================================================ +// Context Patterns +// ============================================================================ + +// Type-safe context +interface AuthContextType { + user: User | null + isAuthenticated: boolean + login: (email: string, password: string) => Promise<void> + logout: () => void +} + +const AuthContext = createContext<AuthContextType | undefined>(undefined) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState<User | null>(null) + + const login = async (email: string, password: string) => { + // Login logic + const userData = await fetch('/api/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }).then((r) => r.json()) + + setUser(userData) + } + + const logout = () => { + setUser(null) + } + + const value: AuthContextType = { + user, + isAuthenticated: user !== null, + login, + logout, + } + + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> +} + +// Custom hook with error handling +export function useAuth(): AuthContextType { + const context = useContext(AuthContext) + + if (context === undefined) { + throw new Error('useAuth must be used within AuthProvider') + } + + return context +} + +// ============================================================================ +// Event Handler Patterns +// ============================================================================ + +interface FormData { + name: string + email: string + message: string +} + +function ContactForm() { + const [formData, setFormData] = useState<FormData>({ + name: '', + email: '', + message: '', + }) + + // Type-safe change handler + const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { + const { name, value } = e.target + setFormData((prev) => ({ + ...prev, + [name]: value, + })) + } + + // Type-safe submit handler + const handleSubmit = (e: FormEvent<HTMLFormElement>) => { + e.preventDefault() + console.log('Submitting:', formData) + } + + // Specific field handler + const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => { + setFormData((prev) => ({ ...prev, name: e.target.value })) + } + + return ( + <form onSubmit={handleSubmit}> + <input + name="name" + value={formData.name} + onChange={handleChange} + placeholder="Name" + /> + <input + name="email" + value={formData.email} + onChange={handleChange} + placeholder="Email" + /> + <textarea + name="message" + value={formData.message} + onChange={handleChange} + placeholder="Message" + /> + <button type="submit">Submit</button> + </form> + ) +} + +// ============================================================================ +// Ref Patterns +// ============================================================================ + +function FocusInput() { + // useRef with DOM element + const inputRef = useRef<HTMLInputElement>(null) + + const focusInput = () => { + inputRef.current?.focus() + } + + return ( + <div> + <input ref={inputRef} /> + <button onClick={focusInput}>Focus Input</button> + </div> + ) +} + +function Timer() { + // useRef for mutable value + const countRef = useRef<number>(0) + const intervalRef = useRef<NodeJS.Timeout | null>(null) + + const startTimer = () => { + intervalRef.current = setInterval(() => { + countRef.current += 1 + console.log(countRef.current) + }, 1000) + } + + const stopTimer = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + + return ( + <div> + <button onClick={startTimer}>Start</button> + <button onClick={stopTimer}>Stop</button> + </div> + ) +} + +// ============================================================================ +// Generic Component Patterns +// ============================================================================ + +// Select component with generic options +interface SelectProps<T> { + options: T[] + value: T + onChange: (value: T) => void + getLabel: (option: T) => string + getValue: (option: T) => string +} + +export function Select<T>({ + options, + value, + onChange, + getLabel, + getValue, +}: SelectProps<T>) { + return ( + <select + value={getValue(value)} + onChange={(e) => { + const selectedValue = e.target.value + const option = options.find((opt) => getValue(opt) === selectedValue) + if (option) { + onChange(option) + } + }} + > + {options.map((option) => ( + <option key={getValue(option)} value={getValue(option)}> + {getLabel(option)} + </option> + ))} + </select> + ) +} + +// Data table component +interface Column<T> { + key: keyof T + header: string + render?: (value: T[keyof T], row: T) => ReactNode +} + +interface TableProps<T> { + data: T[] + columns: Column<T>[] + keyExtractor: (row: T) => string +} + +export function Table<T>({ data, columns, keyExtractor }: TableProps<T>) { + return ( + <table> + <thead> + <tr> + {columns.map((col) => ( + <th key={String(col.key)}>{col.header}</th> + ))} + </tr> + </thead> + <tbody> + {data.map((row) => ( + <tr key={keyExtractor(row)}> + {columns.map((col) => ( + <td key={String(col.key)}> + {col.render ? col.render(row[col.key], row) : String(row[col.key])} + </td> + ))} + </tr> + ))} + </tbody> + </table> + ) +} + +// ============================================================================ +// Higher-Order Component Pattern +// ============================================================================ + +interface WithLoadingProps { + isLoading: boolean +} + +function withLoading<P extends object>( + Component: React.ComponentType<P>, +): React.FC<P & WithLoadingProps> { + return ({ isLoading, ...props }: WithLoadingProps & P) => { + if (isLoading) { + return <div>Loading...</div> + } + + return <Component {...(props as P)} /> + } +} + +// Usage +interface UserListProps { + users: User[] +} + +const UserList: React.FC<UserListProps> = ({ users }) => ( + <ul> + {users.map((user) => ( + <li key={user.id}>{user.name}</li> + ))} + </ul> +) + +const UserListWithLoading = withLoading(UserList) + +// ============================================================================ +// Exports +// ============================================================================ + +export { + useCounter, + useLoadingState, + useFetch, + useFetchWithReducer, + ContactForm, + FocusInput, + Timer, +} + +export type { + ButtonProps, + InputProps, + ListProps, + UseFetchOptions, + UseFetchReturn, + FetchState, + FetchAction, + AuthContextType, + SelectProps, + Column, + TableProps, +} + diff --git a/.claude/skills/typescript/examples/type-system-basics.ts b/.claude/skills/typescript/examples/type-system-basics.ts new file mode 100644 index 00000000..bc9742d5 --- /dev/null +++ b/.claude/skills/typescript/examples/type-system-basics.ts @@ -0,0 +1,361 @@ +/** + * TypeScript Type System Basics + * + * This file demonstrates fundamental TypeScript concepts including: + * - Primitive types + * - Object types (interfaces, type aliases) + * - Union and intersection types + * - Type inference and narrowing + * - Function types + */ + +// ============================================================================ +// Primitive Types +// ============================================================================ + +const message: string = 'Hello, TypeScript!' +const count: number = 42 +const isActive: boolean = true +const nothing: null = null +const notDefined: undefined = undefined + +// ============================================================================ +// Object Types +// ============================================================================ + +// Interface definition +interface User { + id: string + name: string + email: string + age?: number // Optional property + readonly createdAt: Date // Readonly property +} + +// Type alias definition +type Product = { + id: string + name: string + price: number + category: string +} + +// Creating objects +const user: User = { + id: '1', + name: 'Alice', + email: 'alice@example.com', + createdAt: new Date(), +} + +const product: Product = { + id: 'p1', + name: 'Laptop', + price: 999, + category: 'electronics', +} + +// ============================================================================ +// Union Types +// ============================================================================ + +type Status = 'idle' | 'loading' | 'success' | 'error' +type ID = string | number + +function formatId(id: ID): string { + if (typeof id === 'string') { + return id.toUpperCase() + } + return id.toString() +} + +// Discriminated unions +type ApiResponse = + | { success: true; data: User } + | { success: false; error: string } + +function handleResponse(response: ApiResponse) { + if (response.success) { + // TypeScript knows response.data exists here + console.log(response.data.name) + } else { + // TypeScript knows response.error exists here + console.error(response.error) + } +} + +// ============================================================================ +// Intersection Types +// ============================================================================ + +type Timestamped = { + createdAt: Date + updatedAt: Date +} + +type TimestampedUser = User & Timestamped + +const timestampedUser: TimestampedUser = { + id: '1', + name: 'Bob', + email: 'bob@example.com', + createdAt: new Date(), + updatedAt: new Date(), +} + +// ============================================================================ +// Array Types +// ============================================================================ + +const numbers: number[] = [1, 2, 3, 4, 5] +const strings: Array<string> = ['a', 'b', 'c'] +const users: User[] = [user, timestampedUser] + +// Readonly arrays +const immutableNumbers: readonly number[] = [1, 2, 3] +// immutableNumbers.push(4) // Error: push does not exist on readonly array + +// ============================================================================ +// Tuple Types +// ============================================================================ + +type Point = [number, number] +type NamedPoint = [x: number, y: number, z?: number] + +const point: Point = [10, 20] +const namedPoint: NamedPoint = [10, 20, 30] + +// ============================================================================ +// Function Types +// ============================================================================ + +// Function declaration +function add(a: number, b: number): number { + return a + b +} + +// Arrow function +const subtract = (a: number, b: number): number => a - b + +// Function type alias +type MathOperation = (a: number, b: number) => number + +const multiply: MathOperation = (a, b) => a * b + +// Optional parameters +function greet(name: string, greeting?: string): string { + return `${greeting ?? 'Hello'}, ${name}!` +} + +// Default parameters +function createUser(name: string, role: string = 'user'): User { + return { + id: Math.random().toString(), + name, + email: `${name.toLowerCase()}@example.com`, + createdAt: new Date(), + } +} + +// Rest parameters +function sum(...numbers: number[]): number { + return numbers.reduce((acc, n) => acc + n, 0) +} + +// ============================================================================ +// Type Inference +// ============================================================================ + +// Type is inferred as string +let inferredString = 'hello' + +// Type is inferred as number +let inferredNumber = 42 + +// Type is inferred as { name: string; age: number } +let inferredObject = { + name: 'Alice', + age: 30, +} + +// Return type is inferred as number +function inferredReturn(a: number, b: number) { + return a + b +} + +// ============================================================================ +// Type Narrowing +// ============================================================================ + +// typeof guard +function processValue(value: string | number) { + if (typeof value === 'string') { + // value is string here + return value.toUpperCase() + } + // value is number here + return value.toFixed(2) +} + +// Truthiness narrowing +function printName(name: string | null | undefined) { + if (name) { + // name is string here + console.log(name.toUpperCase()) + } +} + +// Equality narrowing +function example(x: string | number, y: string | boolean) { + if (x === y) { + // x and y are both string here + console.log(x.toUpperCase(), y.toLowerCase()) + } +} + +// in operator narrowing +type Fish = { swim: () => void } +type Bird = { fly: () => void } + +function move(animal: Fish | Bird) { + if ('swim' in animal) { + // animal is Fish here + animal.swim() + } else { + // animal is Bird here + animal.fly() + } +} + +// instanceof narrowing +function processError(error: Error | string) { + if (error instanceof Error) { + // error is Error here + console.error(error.message) + } else { + // error is string here + console.error(error) + } +} + +// ============================================================================ +// Type Predicates (Custom Type Guards) +// ============================================================================ + +function isUser(value: unknown): value is User { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'name' in value && + 'email' in value + ) +} + +function processData(data: unknown) { + if (isUser(data)) { + // data is User here + console.log(data.name) + } +} + +// ============================================================================ +// Const Assertions +// ============================================================================ + +// Without const assertion +const mutableConfig = { + host: 'localhost', + port: 8080, +} +// mutableConfig.host = 'example.com' // OK + +// With const assertion +const immutableConfig = { + host: 'localhost', + port: 8080, +} as const +// immutableConfig.host = 'example.com' // Error: cannot assign to readonly property + +// Array with const assertion +const directions = ['north', 'south', 'east', 'west'] as const +// Type: readonly ["north", "south", "east", "west"] + +// ============================================================================ +// Literal Types +// ============================================================================ + +type Direction = 'north' | 'south' | 'east' | 'west' +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' +type DiceValue = 1 | 2 | 3 | 4 | 5 | 6 + +function move(direction: Direction, steps: number) { + console.log(`Moving ${direction} by ${steps} steps`) +} + +move('north', 10) // OK +// move('up', 10) // Error: "up" is not assignable to Direction + +// ============================================================================ +// Index Signatures +// ============================================================================ + +interface StringMap { + [key: string]: string +} + +const translations: StringMap = { + hello: 'Hola', + goodbye: 'Adiós', + thanks: 'Gracias', +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +// Type-safe object keys +function getObjectKeys<T extends object>(obj: T): Array<keyof T> { + return Object.keys(obj) as Array<keyof T> +} + +// Type-safe property access +function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { + return obj[key] +} + +const userName = getProperty(user, 'name') // Type: string +const userAge = getProperty(user, 'age') // Type: number | undefined + +// ============================================================================ +// Named Return Values (Go-style) +// ============================================================================ + +function parseJSON(json: string): { data: unknown | null; err: Error | null } { + let data: unknown | null = null + let err: Error | null = null + + try { + data = JSON.parse(json) + } catch (error) { + err = error instanceof Error ? error : new Error(String(error)) + } + + return { data, err } +} + +// Usage +const { data, err } = parseJSON('{"name": "Alice"}') +if (err) { + console.error('Failed to parse JSON:', err.message) +} else { + console.log('Parsed data:', data) +} + +// ============================================================================ +// Exports +// ============================================================================ + +export type { User, Product, Status, ID, ApiResponse, TimestampedUser } +export { formatId, handleResponse, processValue, isUser, getProperty, parseJSON } + diff --git a/.claude/skills/typescript/quick-reference.md b/.claude/skills/typescript/quick-reference.md new file mode 100644 index 00000000..260d1c6d --- /dev/null +++ b/.claude/skills/typescript/quick-reference.md @@ -0,0 +1,395 @@ +# TypeScript Quick Reference + +Quick lookup guide for common TypeScript patterns and syntax. + +## Basic Types + +```typescript +// Primitives +string, number, boolean, null, undefined, symbol, bigint + +// Special types +any // Avoid - disables type checking +unknown // Type-safe alternative to any +void // No return value +never // Never returns + +// Arrays +number[] +Array<string> +readonly number[] + +// Tuples +[string, number] +[x: number, y: number] + +// Objects +{ name: string; age: number } +Record<string, number> +``` + +## Type Declarations + +```typescript +// Interface +interface User { + id: string + name: string + age?: number // Optional + readonly createdAt: Date // Readonly +} + +// Type alias +type Status = 'idle' | 'loading' | 'success' | 'error' +type ID = string | number +type Point = { x: number; y: number } + +// Function type +type Callback = (data: string) => void +type MathOp = (a: number, b: number) => number +``` + +## Union & Intersection + +```typescript +// Union (OR) +string | number +type Result = Success | Error + +// Intersection (AND) +A & B +type Combined = User & Timestamped + +// Discriminated union +type State = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; data: Data } + | { status: 'error'; error: Error } +``` + +## Generics + +```typescript +// Generic function +function identity<T>(value: T): T + +// Generic interface +interface Box<T> { value: T } + +// Generic with constraint +function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] + +// Multiple type parameters +function merge<T, U>(a: T, b: U): T & U + +// Default type parameter +interface Response<T = unknown> { data: T } +``` + +## Utility Types + +```typescript +Partial<T> // Make all optional +Required<T> // Make all required +Readonly<T> // Make all readonly +Pick<T, K> // Select properties +Omit<T, K> // Exclude properties +Record<K, T> // Object with specific keys +Exclude<T, U> // Remove from union +Extract<T, U> // Extract from union +NonNullable<T> // Remove null/undefined +ReturnType<T> // Get function return type +Parameters<T> // Get function parameters +Awaited<T> // Unwrap Promise +``` + +## Type Guards + +```typescript +// typeof +if (typeof value === 'string') { } + +// instanceof +if (error instanceof Error) { } + +// in operator +if ('property' in object) { } + +// Custom type guard +function isUser(value: unknown): value is User { + return typeof value === 'object' && value !== null && 'id' in value +} + +// Assertion function +function assertIsString(value: unknown): asserts value is string { + if (typeof value !== 'string') throw new Error() +} +``` + +## Advanced Types + +```typescript +// Conditional types +type IsString<T> = T extends string ? true : false + +// Mapped types +type Nullable<T> = { [K in keyof T]: T[K] | null } + +// Template literal types +type EventName<T extends string> = `on${Capitalize<T>}` + +// Key remapping +type Getters<T> = { + [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] +} + +// infer keyword +type Flatten<T> = T extends Array<infer U> ? U : T +``` + +## Functions + +```typescript +// Function declaration +function add(a: number, b: number): number { return a + b } + +// Arrow function +const subtract = (a: number, b: number): number => a - b + +// Optional parameters +function greet(name: string, greeting?: string): string { } + +// Default parameters +function create(name: string, role = 'user'): User { } + +// Rest parameters +function sum(...numbers: number[]): number { } + +// Overloads +function format(value: string): string +function format(value: number): string +function format(value: string | number): string { } +``` + +## Classes + +```typescript +class User { + // Properties + private id: string + public name: string + protected age: number + readonly createdAt: Date + + // Constructor + constructor(name: string) { + this.name = name + this.createdAt = new Date() + } + + // Methods + greet(): string { + return `Hello, ${this.name}` + } + + // Static + static create(name: string): User { + return new User(name) + } + + // Getters/Setters + get displayName(): string { + return this.name.toUpperCase() + } +} + +// Inheritance +class Admin extends User { + constructor(name: string, public permissions: string[]) { + super(name) + } +} + +// Abstract class +abstract class Animal { + abstract makeSound(): void +} +``` + +## React Patterns + +```typescript +// Component props +interface ButtonProps { + variant?: 'primary' | 'secondary' + onClick?: () => void + children: React.ReactNode +} + +export function Button({ variant = 'primary', onClick, children }: ButtonProps) { } + +// Generic component +interface ListProps<T> { + items: T[] + renderItem: (item: T) => React.ReactNode +} + +export function List<T>({ items, renderItem }: ListProps<T>) { } + +// Hooks +const [state, setState] = useState<string>('') +const [data, setData] = useState<User | null>(null) + +// Context +interface AuthContextType { + user: User | null + login: () => Promise<void> +} + +const AuthContext = createContext<AuthContextType | undefined>(undefined) + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext) + if (!context) throw new Error('useAuth must be used within AuthProvider') + return context +} +``` + +## Common Patterns + +### Result Type +```typescript +type Result<T, E = Error> = + | { success: true; data: T } + | { success: false; error: E } +``` + +### Option Type +```typescript +type Option<T> = Some<T> | None +interface Some<T> { _tag: 'Some'; value: T } +interface None { _tag: 'None' } +``` + +### Branded Types +```typescript +type Brand<K, T> = K & { __brand: T } +type UserId = Brand<string, 'UserId'> +``` + +### Named Returns (Go-style) +```typescript +function parseJSON(json: string): { data: unknown | null; err: Error | null } { + let data: unknown | null = null + let err: Error | null = null + + try { + data = JSON.parse(json) + } catch (error) { + err = error instanceof Error ? error : new Error(String(error)) + } + + return { data, err } +} +``` + +## Type Assertions + +```typescript +// as syntax (preferred) +const value = input as string + +// Angle bracket syntax (not in JSX) +const value = <string>input + +// as const +const config = { host: 'localhost' } as const + +// Non-null assertion (use sparingly) +const element = document.getElementById('app')! +``` + +## Type Narrowing + +```typescript +// Control flow +if (value !== null) { + // value is non-null here +} + +// Switch with discriminated unions +switch (state.status) { + case 'success': + console.log(state.data) // TypeScript knows data exists + break + case 'error': + console.log(state.error) // TypeScript knows error exists + break +} + +// Optional chaining +user?.profile?.name + +// Nullish coalescing +const name = user?.name ?? 'Anonymous' +``` + +## Module Syntax + +```typescript +// Named exports +export function helper() { } +export const CONFIG = { } + +// Default export +export default class App { } + +// Type-only imports/exports +import type { User } from './types' +export type { User } + +// Namespace imports +import * as utils from './utils' +``` + +## TSConfig Essentials + +```json +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +} +``` + +## Common Errors & Fixes + +| Error | Fix | +|-------|-----| +| Type 'X' is not assignable to type 'Y' | Check type compatibility, use type assertion if needed | +| Object is possibly 'null' | Use optional chaining `?.` or null check | +| Cannot find module | Install `@types/package-name` | +| Implicit any | Add type annotation or enable strict mode | +| Property does not exist | Check object shape, use type guard | + +## Best Practices + +1. Enable `strict` mode in tsconfig.json +2. Avoid `any`, use `unknown` instead +3. Use discriminated unions for state +4. Leverage type inference +5. Use `const` assertions for immutable data +6. Create custom type guards for runtime safety +7. Use utility types instead of recreating +8. Document complex types with JSDoc +9. Prefer interfaces for objects, types for unions +10. Use branded types for domain-specific primitives + diff --git a/.claude/skills/typescript/references/common-patterns.md b/.claude/skills/typescript/references/common-patterns.md new file mode 100644 index 00000000..b73d42b6 --- /dev/null +++ b/.claude/skills/typescript/references/common-patterns.md @@ -0,0 +1,756 @@ +# TypeScript Common Patterns Reference + +This document contains commonly used TypeScript patterns and idioms from real-world applications. + +## React Patterns + +### Component Props + +```typescript +// Basic props with children +interface ButtonProps { + variant?: 'primary' | 'secondary' | 'tertiary' + size?: 'sm' | 'md' | 'lg' + disabled?: boolean + onClick?: () => void + children: React.ReactNode +} + +export function Button({ + variant = 'primary', + size = 'md', + disabled = false, + onClick, + children, +}: ButtonProps) { + return ( + <button className={`btn-${variant} btn-${size}`} disabled={disabled} onClick={onClick}> + {children} + </button> + ) +} + +// Props extending HTML attributes +interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { + label?: string + error?: string +} + +export function Input({ label, error, ...inputProps }: InputProps) { + return ( + <div> + {label && <label>{label}</label>} + <input {...inputProps} /> + {error && <span>{error}</span>} + </div> + ) +} + +// Generic component props +interface ListProps<T> { + items: T[] + renderItem: (item: T) => React.ReactNode + keyExtractor: (item: T) => string +} + +export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { + return ( + <ul> + {items.map((item) => ( + <li key={keyExtractor(item)}>{renderItem(item)}</li> + ))} + </ul> + ) +} +``` + +### Hooks + +```typescript +// Custom hook with return type +function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] { + const [storedValue, setStoredValue] = useState<T>(() => { + try { + const item = window.localStorage.getItem(key) + return item ? JSON.parse(item) : initialValue + } catch (error) { + return initialValue + } + }) + + const setValue = (value: T) => { + setStoredValue(value) + window.localStorage.setItem(key, JSON.stringify(value)) + } + + return [storedValue, setValue] +} + +// Hook with options object +interface UseFetchOptions<T> { + initialData?: T + onSuccess?: (data: T) => void + onError?: (error: Error) => void +} + +function useFetch<T>(url: string, options?: UseFetchOptions<T>) { + const [data, setData] = useState<T | undefined>(options?.initialData) + const [loading, setLoading] = useState(false) + const [error, setError] = useState<Error | null>(null) + + useEffect(() => { + let isCancelled = false + + const fetchData = async () => { + setLoading(true) + try { + const response = await fetch(url) + const json = await response.json() + if (!isCancelled) { + setData(json) + options?.onSuccess?.(json) + } + } catch (err) { + if (!isCancelled) { + const error = err instanceof Error ? err : new Error(String(err)) + setError(error) + options?.onError?.(error) + } + } finally { + if (!isCancelled) { + setLoading(false) + } + } + } + + fetchData() + + return () => { + isCancelled = true + } + }, [url]) + + return { data, loading, error } +} +``` + +### Context + +```typescript +// Type-safe context +interface AuthContextType { + user: User | null + login: (email: string, password: string) => Promise<void> + logout: () => void + isAuthenticated: boolean +} + +const AuthContext = createContext<AuthContextType | undefined>(undefined) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState<User | null>(null) + + const login = async (email: string, password: string) => { + // Login logic + const user = await api.login(email, password) + setUser(user) + } + + const logout = () => { + setUser(null) + } + + const value: AuthContextType = { + user, + login, + logout, + isAuthenticated: user !== null, + } + + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> +} + +// Custom hook with proper error handling +export function useAuth(): AuthContextType { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within AuthProvider') + } + return context +} +``` + +## API Response Patterns + +### Result Type Pattern + +```typescript +// Discriminated union for API responses +type Result<T, E = Error> = + | { success: true; data: T } + | { success: false; error: E } + +// Helper functions +function success<T>(data: T): Result<T> { + return { success: true, data } +} + +function failure<E = Error>(error: E): Result<never, E> { + return { success: false, error } +} + +// Usage +async function fetchUser(id: string): Promise<Result<User>> { + try { + const response = await fetch(`/api/users/${id}`) + if (!response.ok) { + return failure(new Error(`HTTP ${response.status}`)) + } + const data = await response.json() + return success(data) + } catch (error) { + return failure(error instanceof Error ? error : new Error(String(error))) + } +} + +// Consuming the result +const result = await fetchUser('123') +if (result.success) { + console.log(result.data.name) // Type-safe access +} else { + console.error(result.error.message) // Type-safe error handling +} +``` + +### Option Type Pattern + +```typescript +// Option/Maybe type for nullable values +type Option<T> = Some<T> | None + +interface Some<T> { + readonly _tag: 'Some' + readonly value: T +} + +interface None { + readonly _tag: 'None' +} + +// Constructors +function some<T>(value: T): Option<T> { + return { _tag: 'Some', value } +} + +function none(): Option<never> { + return { _tag: 'None' } +} + +// Helper functions +function isSome<T>(option: Option<T>): option is Some<T> { + return option._tag === 'Some' +} + +function isNone<T>(option: Option<T>): option is None { + return option._tag === 'None' +} + +function map<T, U>(option: Option<T>, fn: (value: T) => U): Option<U> { + return isSome(option) ? some(fn(option.value)) : none() +} + +function getOrElse<T>(option: Option<T>, defaultValue: T): T { + return isSome(option) ? option.value : defaultValue +} + +// Usage +function findUser(id: string): Option<User> { + const user = users.find((u) => u.id === id) + return user ? some(user) : none() +} + +const user = findUser('123') +const userName = getOrElse(map(user, (u) => u.name), 'Unknown') +``` + +## State Management Patterns + +### Discriminated Union for State + +```typescript +// State machine using discriminated unions +type FetchState<T> = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; data: T } + | { status: 'error'; error: Error } + +// Reducer pattern +type FetchAction<T> = + | { type: 'FETCH_START' } + | { type: 'FETCH_SUCCESS'; payload: T } + | { type: 'FETCH_ERROR'; error: Error } + | { type: 'RESET' } + +function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> { + switch (action.type) { + case 'FETCH_START': + return { status: 'loading' } + case 'FETCH_SUCCESS': + return { status: 'success', data: action.payload } + case 'FETCH_ERROR': + return { status: 'error', error: action.error } + case 'RESET': + return { status: 'idle' } + } +} + +// Usage in component +function UserProfile({ userId }: { userId: string }) { + const [state, dispatch] = useReducer(fetchReducer<User>, { status: 'idle' }) + + useEffect(() => { + dispatch({ type: 'FETCH_START' }) + fetchUser(userId) + .then((user) => dispatch({ type: 'FETCH_SUCCESS', payload: user })) + .catch((error) => dispatch({ type: 'FETCH_ERROR', error })) + }, [userId]) + + switch (state.status) { + case 'idle': + return <div>Ready to load</div> + case 'loading': + return <div>Loading...</div> + case 'success': + return <div>{state.data.name}</div> + case 'error': + return <div>Error: {state.error.message}</div> + } +} +``` + +### Store Pattern + +```typescript +// Type-safe store implementation +interface Store<T> { + getState: () => T + setState: (partial: Partial<T>) => void + subscribe: (listener: (state: T) => void) => () => void +} + +function createStore<T>(initialState: T): Store<T> { + let state = initialState + const listeners = new Set<(state: T) => void>() + + return { + getState: () => state, + setState: (partial) => { + state = { ...state, ...partial } + listeners.forEach((listener) => listener(state)) + }, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} + +// Usage +interface AppState { + user: User | null + theme: 'light' | 'dark' +} + +const store = createStore<AppState>({ + user: null, + theme: 'light', +}) + +// React hook integration +function useStore<T, U>(store: Store<T>, selector: (state: T) => U): U { + const [value, setValue] = useState(() => selector(store.getState())) + + useEffect(() => { + const unsubscribe = store.subscribe((state) => { + setValue(selector(state)) + }) + return unsubscribe + }, [store, selector]) + + return value +} + +// Usage in component +function ThemeToggle() { + const theme = useStore(store, (state) => state.theme) + + return ( + <button + onClick={() => store.setState({ theme: theme === 'light' ? 'dark' : 'light' })} + > + Toggle Theme + </button> + ) +} +``` + +## Form Patterns + +### Form State Management + +```typescript +// Generic form state +interface FormState<T> { + values: T + errors: Partial<Record<keyof T, string>> + touched: Partial<Record<keyof T, boolean>> + isSubmitting: boolean +} + +// Form hook +function useForm<T extends Record<string, any>>( + initialValues: T, + validate: (values: T) => Partial<Record<keyof T, string>>, +) { + const [state, setState] = useState<FormState<T>>({ + values: initialValues, + errors: {}, + touched: {}, + isSubmitting: false, + }) + + const handleChange = <K extends keyof T>(field: K, value: T[K]) => { + setState((prev) => ({ + ...prev, + values: { ...prev.values, [field]: value }, + errors: { ...prev.errors, [field]: undefined }, + })) + } + + const handleBlur = <K extends keyof T>(field: K) => { + setState((prev) => ({ + ...prev, + touched: { ...prev.touched, [field]: true }, + })) + } + + const handleSubmit = async (onSubmit: (values: T) => Promise<void>) => { + const errors = validate(state.values) + + if (Object.keys(errors).length > 0) { + setState((prev) => ({ + ...prev, + errors, + touched: Object.keys(state.values).reduce( + (acc, key) => ({ ...acc, [key]: true }), + {}, + ), + })) + return + } + + setState((prev) => ({ ...prev, isSubmitting: true })) + try { + await onSubmit(state.values) + } finally { + setState((prev) => ({ ...prev, isSubmitting: false })) + } + } + + return { + values: state.values, + errors: state.errors, + touched: state.touched, + isSubmitting: state.isSubmitting, + handleChange, + handleBlur, + handleSubmit, + } +} + +// Usage +interface LoginFormValues { + email: string + password: string +} + +function LoginForm() { + const form = useForm<LoginFormValues>( + { email: '', password: '' }, + (values) => { + const errors: Partial<Record<keyof LoginFormValues, string>> = {} + if (!values.email) { + errors.email = 'Email is required' + } + if (!values.password) { + errors.password = 'Password is required' + } + return errors + }, + ) + + return ( + <form + onSubmit={(e) => { + e.preventDefault() + form.handleSubmit(async (values) => { + await login(values.email, values.password) + }) + }} + > + <input + value={form.values.email} + onChange={(e) => form.handleChange('email', e.target.value)} + onBlur={() => form.handleBlur('email')} + /> + {form.touched.email && form.errors.email && <span>{form.errors.email}</span>} + + <input + type="password" + value={form.values.password} + onChange={(e) => form.handleChange('password', e.target.value)} + onBlur={() => form.handleBlur('password')} + /> + {form.touched.password && form.errors.password && ( + <span>{form.errors.password}</span> + )} + + <button type="submit" disabled={form.isSubmitting}> + Login + </button> + </form> + ) +} +``` + +## Validation Patterns + +### Zod Integration + +```typescript +import { z } from 'zod' + +// Schema definition +const userSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(100), + email: z.string().email(), + age: z.number().int().min(0).max(120), + role: z.enum(['admin', 'user', 'guest']), +}) + +// Extract type from schema +type User = z.infer<typeof userSchema> + +// Validation function +function validateUser(data: unknown): Result<User> { + const result = userSchema.safeParse(data) + if (result.success) { + return { success: true, data: result.data } + } + return { + success: false, + error: new Error(result.error.errors.map((e) => e.message).join(', ')), + } +} + +// API integration +async function createUser(data: unknown): Promise<Result<User>> { + const validation = validateUser(data) + if (!validation.success) { + return validation + } + + try { + const response = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validation.data), + }) + + if (!response.ok) { + return failure(new Error(`HTTP ${response.status}`)) + } + + const user = await response.json() + return success(user) + } catch (error) { + return failure(error instanceof Error ? error : new Error(String(error))) + } +} +``` + +## Builder Pattern + +```typescript +// Fluent builder pattern +class QueryBuilder<T> { + private filters: Array<(item: T) => boolean> = [] + private sortFn?: (a: T, b: T) => number + private limitValue?: number + + where(predicate: (item: T) => boolean): this { + this.filters.push(predicate) + return this + } + + sortBy(compareFn: (a: T, b: T) => number): this { + this.sortFn = compareFn + return this + } + + limit(count: number): this { + this.limitValue = count + return this + } + + execute(data: T[]): T[] { + let result = data + + // Apply filters + this.filters.forEach((filter) => { + result = result.filter(filter) + }) + + // Apply sorting + if (this.sortFn) { + result = result.sort(this.sortFn) + } + + // Apply limit + if (this.limitValue !== undefined) { + result = result.slice(0, this.limitValue) + } + + return result + } +} + +// Usage +interface Product { + id: string + name: string + price: number + category: string +} + +const products: Product[] = [ + /* ... */ +] + +const query = new QueryBuilder<Product>() + .where((p) => p.category === 'electronics') + .where((p) => p.price < 1000) + .sortBy((a, b) => a.price - b.price) + .limit(10) + .execute(products) +``` + +## Factory Pattern + +```typescript +// Abstract factory pattern with TypeScript +interface Button { + render: () => string + onClick: () => void +} + +interface ButtonFactory { + createButton: (label: string, onClick: () => void) => Button +} + +class PrimaryButton implements Button { + constructor(private label: string, private clickHandler: () => void) {} + + render() { + return `<button class="primary">${this.label}</button>` + } + + onClick() { + this.clickHandler() + } +} + +class SecondaryButton implements Button { + constructor(private label: string, private clickHandler: () => void) {} + + render() { + return `<button class="secondary">${this.label}</button>` + } + + onClick() { + this.clickHandler() + } +} + +class PrimaryButtonFactory implements ButtonFactory { + createButton(label: string, onClick: () => void): Button { + return new PrimaryButton(label, onClick) + } +} + +class SecondaryButtonFactory implements ButtonFactory { + createButton(label: string, onClick: () => void): Button { + return new SecondaryButton(label, onClick) + } +} + +// Usage +function createUI(factory: ButtonFactory) { + const button = factory.createButton('Click me', () => console.log('Clicked!')) + return button.render() +} +``` + +## Named Return Variables Pattern + +```typescript +// Following Go-style named returns +function parseUser(data: unknown): { user: User | null; err: Error | null } { + let user: User | null = null + let err: Error | null = null + + try { + user = userSchema.parse(data) + } catch (error) { + err = error instanceof Error ? error : new Error(String(error)) + } + + return { user, err } +} + +// With explicit naming +function fetchData(url: string): { + data: unknown | null + status: number + err: Error | null +} { + let data: unknown | null = null + let status = 0 + let err: Error | null = null + + try { + const response = fetch(url) + // Process response + } catch (error) { + err = error instanceof Error ? error : new Error(String(error)) + } + + return { data, status, err } +} +``` + +## Best Practices + +1. **Use discriminated unions** for type-safe state management +2. **Leverage generic types** for reusable components and hooks +3. **Extract types from Zod schemas** for runtime + compile-time safety +4. **Use Result/Option types** for explicit error handling +5. **Create builder patterns** for complex object construction +6. **Use factory patterns** for flexible object creation +7. **Type context properly** to catch usage errors at compile time +8. **Prefer const assertions** for immutable configurations +9. **Use branded types** for domain-specific primitives +10. **Document patterns** with JSDoc for team knowledge sharing + diff --git a/.claude/skills/typescript/references/type-system.md b/.claude/skills/typescript/references/type-system.md new file mode 100644 index 00000000..c7cac7b9 --- /dev/null +++ b/.claude/skills/typescript/references/type-system.md @@ -0,0 +1,804 @@ +# TypeScript Type System Reference + +## Overview + +TypeScript's type system is structural (duck-typed) rather than nominal. Two types are compatible if their structure matches, regardless of their names. + +## Primitive Types + +### Basic Primitives + +```typescript +let str: string = 'hello' +let num: number = 42 +let bool: boolean = true +let nul: null = null +let undef: undefined = undefined +let sym: symbol = Symbol('key') +let big: bigint = 100n +``` + +### Special Types + +**any** - Disables type checking (avoid when possible): +```typescript +let anything: any = 'string' +anything = 42 // OK +anything.nonExistent() // OK at compile time, error at runtime +``` + +**unknown** - Type-safe alternative to any (requires type checking): +```typescript +let value: unknown = 'string' +// value.toUpperCase() // Error: must narrow type first + +if (typeof value === 'string') { + value.toUpperCase() // OK after narrowing +} +``` + +**void** - Absence of a value (function return type): +```typescript +function log(message: string): void { + console.log(message) +} +``` + +**never** - Value that never occurs (exhaustive checks, infinite loops): +```typescript +function throwError(message: string): never { + throw new Error(message) +} + +function exhaustiveCheck(value: never): never { + throw new Error(`Unhandled case: ${value}`) +} +``` + +## Object Types + +### Interfaces + +```typescript +// Basic interface +interface User { + id: string + name: string + email: string +} + +// Optional properties +interface Product { + id: string + name: string + description?: string // Optional +} + +// Readonly properties +interface Config { + readonly apiUrl: string + readonly timeout: number +} + +// Index signatures +interface Dictionary { + [key: string]: string +} + +// Method signatures +interface Calculator { + add(a: number, b: number): number + subtract(a: number, b: number): number +} + +// Extending interfaces +interface Employee extends User { + role: string + department: string +} + +// Multiple inheritance +interface Admin extends User, Employee { + permissions: string[] +} +``` + +### Type Aliases + +```typescript +// Basic type alias +type ID = string | number + +// Object type +type Point = { + x: number + y: number +} + +// Union type +type Status = 'idle' | 'loading' | 'success' | 'error' + +// Intersection type +type Timestamped = { + createdAt: Date + updatedAt: Date +} + +type TimestampedUser = User & Timestamped + +// Function type +type Callback = (data: string) => void + +// Generic type alias +type Result<T> = { success: true; data: T } | { success: false; error: string } +``` + +### Interface vs Type Alias + +**Use interface when:** +- Defining object shapes +- Need declaration merging +- Building public API types that others might extend + +**Use type when:** +- Creating unions or intersections +- Working with mapped types +- Need conditional types +- Defining primitive aliases + +## Array and Tuple Types + +### Arrays + +```typescript +// Array syntax +let numbers: number[] = [1, 2, 3] +let strings: Array<string> = ['a', 'b', 'c'] + +// Readonly arrays +let immutable: readonly number[] = [1, 2, 3] +let alsoImmutable: ReadonlyArray<string> = ['a', 'b'] +``` + +### Tuples + +```typescript +// Fixed-length, mixed-type arrays +type Point = [number, number] +type NamedPoint = [x: number, y: number] + +// Optional elements +type OptionalTuple = [string, number?] + +// Rest elements +type StringNumberBooleans = [string, number, ...boolean[]] + +// Readonly tuples +type ReadonlyPair = readonly [string, number] +``` + +## Union and Intersection Types + +### Union Types + +```typescript +// Value can be one of several types +type StringOrNumber = string | number + +function format(value: StringOrNumber): string { + if (typeof value === 'string') { + return value + } + return value.toString() +} + +// Discriminated unions +type Shape = + | { kind: 'circle'; radius: number } + | { kind: 'square'; size: number } + | { kind: 'rectangle'; width: number; height: number } + +function area(shape: Shape): number { + switch (shape.kind) { + case 'circle': + return Math.PI * shape.radius ** 2 + case 'square': + return shape.size ** 2 + case 'rectangle': + return shape.width * shape.height + } +} +``` + +### Intersection Types + +```typescript +// Combine multiple types +type Draggable = { + drag: () => void +} + +type Resizable = { + resize: () => void +} + +type UIWidget = Draggable & Resizable + +const widget: UIWidget = { + drag: () => console.log('dragging'), + resize: () => console.log('resizing'), +} +``` + +## Literal Types + +### String Literal Types + +```typescript +type Direction = 'north' | 'south' | 'east' | 'west' +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' + +function move(direction: Direction) { + // direction can only be one of the four values +} +``` + +### Number Literal Types + +```typescript +type DiceValue = 1 | 2 | 3 | 4 | 5 | 6 +type PowerOfTwo = 1 | 2 | 4 | 8 | 16 | 32 +``` + +### Boolean Literal Types + +```typescript +type Yes = true +type No = false +``` + +### Template Literal Types + +```typescript +// String manipulation at type level +type EventName<T extends string> = `on${Capitalize<T>}` +type ClickEvent = EventName<'click'> // "onClick" + +// Combining literals +type Color = 'red' | 'blue' | 'green' +type Shade = 'light' | 'dark' +type ColorShade = `${Shade}-${Color}` // "light-red" | "light-blue" | ... + +// Extract patterns +type EmailLocaleIDs = 'welcome_email' | 'email_heading' +type FooterLocaleIDs = 'footer_title' | 'footer_sendoff' +type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id` +``` + +## Type Inference + +### Automatic Inference + +```typescript +// Type inferred as string +let message = 'hello' + +// Type inferred as number[] +let numbers = [1, 2, 3] + +// Type inferred as { name: string; age: number } +let person = { + name: 'Alice', + age: 30, +} + +// Return type inferred +function add(a: number, b: number) { + return a + b // Returns number +} +``` + +### Const Assertions + +```typescript +// Without const assertion +let colors1 = ['red', 'green', 'blue'] // Type: string[] + +// With const assertion +let colors2 = ['red', 'green', 'blue'] as const // Type: readonly ["red", "green", "blue"] + +// Object with const assertion +const config = { + host: 'localhost', + port: 8080, +} as const // All properties become readonly with literal types +``` + +### Type Inference in Generics + +```typescript +// Generic type inference from usage +function identity<T>(value: T): T { + return value +} + +let str = identity('hello') // T inferred as string +let num = identity(42) // T inferred as number + +// Multiple type parameters +function pair<T, U>(first: T, second: U): [T, U] { + return [first, second] +} + +let p = pair('hello', 42) // [string, number] +``` + +## Type Narrowing + +### typeof Guards + +```typescript +function padLeft(value: string, padding: string | number) { + if (typeof padding === 'number') { + // padding is number here + return ' '.repeat(padding) + value + } + // padding is string here + return padding + value +} +``` + +### instanceof Guards + +```typescript +class Dog { + bark() { + console.log('Woof!') + } +} + +class Cat { + meow() { + console.log('Meow!') + } +} + +function makeSound(animal: Dog | Cat) { + if (animal instanceof Dog) { + animal.bark() + } else { + animal.meow() + } +} +``` + +### in Operator + +```typescript +type Fish = { swim: () => void } +type Bird = { fly: () => void } + +function move(animal: Fish | Bird) { + if ('swim' in animal) { + animal.swim() + } else { + animal.fly() + } +} +``` + +### Equality Narrowing + +```typescript +function example(x: string | number, y: string | boolean) { + if (x === y) { + // x and y are both string here + x.toUpperCase() + y.toLowerCase() + } +} +``` + +### Control Flow Analysis + +```typescript +function example(value: string | null) { + if (value === null) { + return + } + // value is string here (null eliminated) + console.log(value.toUpperCase()) +} +``` + +### Type Predicates (Custom Type Guards) + +```typescript +function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function example(value: unknown) { + if (isString(value)) { + // value is string here + console.log(value.toUpperCase()) + } +} + +// More complex example +interface User { + id: string + name: string +} + +function isUser(value: unknown): value is User { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'name' in value && + typeof (value as User).id === 'string' && + typeof (value as User).name === 'string' + ) +} +``` + +### Assertion Functions + +```typescript +function assert(condition: unknown, message?: string): asserts condition { + if (!condition) { + throw new Error(message || 'Assertion failed') + } +} + +function assertIsString(value: unknown): asserts value is string { + if (typeof value !== 'string') { + throw new Error('Value must be a string') + } +} + +function example(value: unknown) { + assertIsString(value) + // value is string here + console.log(value.toUpperCase()) +} +``` + +## Generic Types + +### Basic Generics + +```typescript +// Generic function +function first<T>(items: T[]): T | undefined { + return items[0] +} + +// Generic interface +interface Box<T> { + value: T +} + +// Generic type alias +type Result<T> = { success: true; data: T } | { success: false; error: string } + +// Generic class +class Stack<T> { + private items: T[] = [] + + push(item: T) { + this.items.push(item) + } + + pop(): T | undefined { + return this.items.pop() + } +} +``` + +### Generic Constraints + +```typescript +// Constrain to specific type +function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { + return obj[key] +} + +// Constrain to interface +interface HasLength { + length: number +} + +function logLength<T extends HasLength>(item: T): void { + console.log(item.length) +} + +logLength('string') // OK +logLength([1, 2, 3]) // OK +logLength({ length: 10 }) // OK +// logLength(42) // Error: number doesn't have length +``` + +### Default Generic Parameters + +```typescript +interface Response<T = unknown> { + data: T + status: number +} + +// Uses default +let response1: Response = { data: 'anything', status: 200 } + +// Explicitly typed +let response2: Response<User> = { data: user, status: 200 } +``` + +### Generic Utility Functions + +```typescript +// Pick specific properties +function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> { + const result = {} as Pick<T, K> + keys.forEach((key) => { + result[key] = obj[key] + }) + return result +} + +// Map array +function map<T, U>(items: T[], fn: (item: T) => U): U[] { + return items.map(fn) +} +``` + +## Advanced Type Features + +### Conditional Types + +```typescript +// Basic conditional type +type IsString<T> = T extends string ? true : false + +type A = IsString<string> // true +type B = IsString<number> // false + +// Distributive conditional types +type ToArray<T> = T extends any ? T[] : never + +type StrArrOrNumArr = ToArray<string | number> // string[] | number[] + +// Infer keyword +type Flatten<T> = T extends Array<infer U> ? U : T + +type Str = Flatten<string[]> // string +type Num = Flatten<number> // number + +// ReturnType implementation +type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never +``` + +### Mapped Types + +```typescript +// Make all properties optional +type Partial<T> = { + [K in keyof T]?: T[K] +} + +// Make all properties required +type Required<T> = { + [K in keyof T]-?: T[K] +} + +// Make all properties readonly +type Readonly<T> = { + readonly [K in keyof T]: T[K] +} + +// Transform keys +type Getters<T> = { + [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] +} + +interface Person { + name: string + age: number +} + +type PersonGetters = Getters<Person> +// { +// getName: () => string +// getAge: () => number +// } +``` + +### Key Remapping + +```typescript +// Filter keys +type RemoveKindField<T> = { + [K in keyof T as Exclude<K, 'kind'>]: T[K] +} + +// Conditional key inclusion +type PickByType<T, U> = { + [K in keyof T as T[K] extends U ? K : never]: T[K] +} + +interface Model { + id: number + name: string + age: number + email: string +} + +type StringFields = PickByType<Model, string> // { name: string, email: string } +``` + +### Recursive Types + +```typescript +// JSON value type +type JSONValue = string | number | boolean | null | JSONObject | JSONArray + +interface JSONObject { + [key: string]: JSONValue +} + +interface JSONArray extends Array<JSONValue> {} + +// Tree structure +interface TreeNode<T> { + value: T + children?: TreeNode<T>[] +} + +// Deep readonly +type DeepReadonly<T> = { + readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] +} +``` + +## Type Compatibility + +### Structural Typing + +```typescript +interface Point { + x: number + y: number +} + +interface Named { + name: string +} + +// Compatible if structure matches +let point: Point = { x: 0, y: 0 } +let namedPoint = { x: 0, y: 0, name: 'origin' } + +point = namedPoint // OK: namedPoint has x and y +``` + +### Variance + +**Covariance** (return types): +```typescript +interface Animal { + name: string +} + +interface Dog extends Animal { + breed: string +} + +let getDog: () => Dog +let getAnimal: () => Animal + +getAnimal = getDog // OK: Dog is assignable to Animal +``` + +**Contravariance** (parameter types): +```typescript +let handleAnimal: (animal: Animal) => void +let handleDog: (dog: Dog) => void + +handleDog = handleAnimal // OK: can pass Dog to function expecting Animal +``` + +## Index Types + +### Index Signatures + +```typescript +// String index +interface StringMap { + [key: string]: string +} + +// Number index +interface NumberArray { + [index: number]: number +} + +// Combine with named properties +interface MixedInterface { + length: number + [index: number]: string +} +``` + +### keyof Operator + +```typescript +interface Person { + name: string + age: number +} + +type PersonKeys = keyof Person // "name" | "age" + +function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { + return obj[key] +} +``` + +### Indexed Access Types + +```typescript +interface Person { + name: string + age: number + address: { + street: string + city: string + } +} + +type Name = Person['name'] // string +type Age = Person['age'] // number +type Address = Person['address'] // { street: string; city: string } +type AddressCity = Person['address']['city'] // string + +// Access multiple keys +type NameOrAge = Person['name' | 'age'] // string | number +``` + +## Branded Types + +```typescript +// Create nominal types from structural types +type Brand<K, T> = K & { __brand: T } + +type USD = Brand<number, 'USD'> +type EUR = Brand<number, 'EUR'> + +function makeUSD(amount: number): USD { + return amount as USD +} + +function makeEUR(amount: number): EUR { + return amount as EUR +} + +let usd = makeUSD(100) +let eur = makeEUR(100) + +// usd = eur // Error: different brands +``` + +## Best Practices + +1. **Prefer type inference** - Let TypeScript infer types when obvious +2. **Use strict null checks** - Enable strictNullChecks for better safety +3. **Avoid `any`** - Use `unknown` and narrow with type guards +4. **Use discriminated unions** - Better than loose unions for state +5. **Leverage const assertions** - Get narrow literal types +6. **Use branded types** - When structural typing isn't enough +7. **Document complex types** - Add JSDoc comments +8. **Extract reusable types** - DRY principle applies to types too +9. **Use utility types** - Leverage built-in transformation types +10. **Test your types** - Use type assertions to verify type correctness + diff --git a/.claude/skills/typescript/references/utility-types.md b/.claude/skills/typescript/references/utility-types.md new file mode 100644 index 00000000..6783be38 --- /dev/null +++ b/.claude/skills/typescript/references/utility-types.md @@ -0,0 +1,666 @@ +# TypeScript Utility Types Reference + +TypeScript provides several built-in utility types that help transform and manipulate types. These are implemented using advanced type features like mapped types and conditional types. + +## Property Modifiers + +### Partial\<T\> + +Makes all properties in `T` optional. + +```typescript +interface User { + id: string + name: string + email: string + age: number +} + +type PartialUser = Partial<User> +// { +// id?: string +// name?: string +// email?: string +// age?: number +// } + +// Useful for update operations +function updateUser(id: string, updates: Partial<User>) { + // Only update provided fields +} + +updateUser('123', { name: 'Alice' }) // OK +updateUser('123', { name: 'Alice', age: 30 }) // OK +``` + +### Required\<T\> + +Makes all properties in `T` required (removes optionality). + +```typescript +interface Config { + host?: string + port?: number + timeout?: number +} + +type RequiredConfig = Required<Config> +// { +// host: string +// port: number +// timeout: number +// } + +function initServer(config: RequiredConfig) { + // All properties are guaranteed to exist + console.log(config.host, config.port, config.timeout) +} +``` + +### Readonly\<T\> + +Makes all properties in `T` readonly. + +```typescript +interface MutablePoint { + x: number + y: number +} + +type ImmutablePoint = Readonly<MutablePoint> +// { +// readonly x: number +// readonly y: number +// } + +const point: ImmutablePoint = { x: 0, y: 0 } +// point.x = 10 // Error: Cannot assign to 'x' because it is a read-only property +``` + +### Mutable\<T\> (Custom) + +Removes readonly modifiers (not built-in, but useful pattern). + +```typescript +type Mutable<T> = { + -readonly [K in keyof T]: T[K] +} + +interface ReadonlyPerson { + readonly name: string + readonly age: number +} + +type MutablePerson = Mutable<ReadonlyPerson> +// { +// name: string +// age: number +// } +``` + +## Property Selection + +### Pick\<T, K\> + +Creates a type by picking specific properties from `T`. + +```typescript +interface User { + id: string + name: string + email: string + password: string + createdAt: Date +} + +type UserProfile = Pick<User, 'id' | 'name' | 'email'> +// { +// id: string +// name: string +// email: string +// } + +// Useful for API responses +function getUserProfile(id: string): UserProfile { + // Return only safe properties +} +``` + +### Omit\<T, K\> + +Creates a type by omitting specific properties from `T`. + +```typescript +interface User { + id: string + name: string + email: string + password: string +} + +type UserWithoutPassword = Omit<User, 'password'> +// { +// id: string +// name: string +// email: string +// } + +// Useful for public user data +function publishUser(user: User): UserWithoutPassword { + const { password, ...publicData } = user + return publicData +} +``` + +## Union Type Utilities + +### Exclude\<T, U\> + +Excludes types from `T` that are assignable to `U`. + +```typescript +type T1 = Exclude<'a' | 'b' | 'c', 'a'> // "b" | "c" +type T2 = Exclude<string | number | boolean, boolean> // string | number + +type EventType = 'click' | 'scroll' | 'mousemove' | 'keypress' +type UIEvent = Exclude<EventType, 'scroll'> // "click" | "mousemove" | "keypress" +``` + +### Extract\<T, U\> + +Extracts types from `T` that are assignable to `U`. + +```typescript +type T1 = Extract<'a' | 'b' | 'c', 'a' | 'f'> // "a" +type T2 = Extract<string | number | boolean, boolean> // boolean + +type Shape = 'circle' | 'square' | 'triangle' | 'rectangle' +type RoundedShape = Extract<Shape, 'circle'> // "circle" +``` + +### NonNullable\<T\> + +Excludes `null` and `undefined` from `T`. + +```typescript +type T1 = NonNullable<string | null | undefined> // string +type T2 = NonNullable<string | number | null> // string | number + +function processValue(value: string | null | undefined) { + if (value !== null && value !== undefined) { + const nonNull: NonNullable<typeof value> = value + // nonNull is guaranteed to be string + } +} +``` + +## Object Construction + +### Record\<K, T\> + +Constructs an object type with keys of type `K` and values of type `T`. + +```typescript +type PageInfo = Record<string, number> +// { [key: string]: number } + +const pages: PageInfo = { + home: 1, + about: 2, + contact: 3, +} + +// Useful for mapped objects +type UserRole = 'admin' | 'user' | 'guest' +type RolePermissions = Record<UserRole, string[]> + +const permissions: RolePermissions = { + admin: ['read', 'write', 'delete'], + user: ['read', 'write'], + guest: ['read'], +} + +// With specific keys +type ThemeColors = Record<'primary' | 'secondary' | 'accent', string> + +const colors: ThemeColors = { + primary: '#007bff', + secondary: '#6c757d', + accent: '#28a745', +} +``` + +## Function Utilities + +### Parameters\<T\> + +Extracts the parameter types of a function type as a tuple. + +```typescript +function createUser(name: string, age: number, email: string) { + // ... +} + +type CreateUserParams = Parameters<typeof createUser> +// [name: string, age: number, email: string] + +// Useful for higher-order functions +function withLogging<T extends (...args: any[]) => any>( + fn: T, + ...args: Parameters<T> +): ReturnType<T> { + console.log('Calling with:', args) + return fn(...args) +} +``` + +### ConstructorParameters\<T\> + +Extracts the parameter types of a constructor function type. + +```typescript +class User { + constructor(public name: string, public age: number) {} +} + +type UserConstructorParams = ConstructorParameters<typeof User> +// [name: string, age: number] + +function createUser(...args: UserConstructorParams): User { + return new User(...args) +} +``` + +### ReturnType\<T\> + +Extracts the return type of a function type. + +```typescript +function createUser() { + return { + id: '123', + name: 'Alice', + email: 'alice@example.com', + } +} + +type User = ReturnType<typeof createUser> +// { +// id: string +// name: string +// email: string +// } + +// Useful with async functions +async function fetchData() { + return { success: true, data: [1, 2, 3] } +} + +type FetchResult = ReturnType<typeof fetchData> +// Promise<{ success: boolean; data: number[] }> + +type UnwrappedResult = Awaited<FetchResult> +// { success: boolean; data: number[] } +``` + +### InstanceType\<T\> + +Extracts the instance type of a constructor function type. + +```typescript +class User { + name: string + constructor(name: string) { + this.name = name + } +} + +type UserInstance = InstanceType<typeof User> +// User + +function processUser(user: UserInstance) { + console.log(user.name) +} +``` + +### ThisParameterType\<T\> + +Extracts the type of the `this` parameter for a function type. + +```typescript +function toHex(this: Number) { + return this.toString(16) +} + +type ThisType = ThisParameterType<typeof toHex> // Number +``` + +### OmitThisParameter\<T\> + +Removes the `this` parameter from a function type. + +```typescript +function toHex(this: Number) { + return this.toString(16) +} + +type PlainFunction = OmitThisParameter<typeof toHex> +// () => string +``` + +## String Manipulation + +### Uppercase\<S\> + +Converts string literal type to uppercase. + +```typescript +type Greeting = 'hello' +type LoudGreeting = Uppercase<Greeting> // "HELLO" + +// Useful for constants +type HttpMethod = 'get' | 'post' | 'put' | 'delete' +type HttpMethodUppercase = Uppercase<HttpMethod> +// "GET" | "POST" | "PUT" | "DELETE" +``` + +### Lowercase\<S\> + +Converts string literal type to lowercase. + +```typescript +type Greeting = 'HELLO' +type QuietGreeting = Lowercase<Greeting> // "hello" +``` + +### Capitalize\<S\> + +Capitalizes the first letter of a string literal type. + +```typescript +type Event = 'click' | 'scroll' | 'mousemove' +type EventHandler = `on${Capitalize<Event>}` +// "onClick" | "onScroll" | "onMousemove" +``` + +### Uncapitalize\<S\> + +Uncapitalizes the first letter of a string literal type. + +```typescript +type Greeting = 'Hello' +type LowerGreeting = Uncapitalize<Greeting> // "hello" +``` + +## Async Utilities + +### Awaited\<T\> + +Unwraps the type of a Promise (recursively). + +```typescript +type T1 = Awaited<Promise<string>> // string +type T2 = Awaited<Promise<Promise<number>>> // number +type T3 = Awaited<boolean | Promise<string>> // boolean | string + +// Useful with async functions +async function fetchUser() { + return { id: '123', name: 'Alice' } +} + +type User = Awaited<ReturnType<typeof fetchUser>> +// { id: string; name: string } +``` + +## Custom Utility Types + +### DeepPartial\<T\> + +Makes all properties and nested properties optional. + +```typescript +type DeepPartial<T> = { + [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] +} + +interface User { + id: string + profile: { + name: string + address: { + street: string + city: string + } + } +} + +type PartialUser = DeepPartial<User> +// All properties at all levels are optional +``` + +### DeepReadonly\<T\> + +Makes all properties and nested properties readonly. + +```typescript +type DeepReadonly<T> = { + readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] +} + +interface User { + id: string + profile: { + name: string + address: { + street: string + city: string + } + } +} + +type ImmutableUser = DeepReadonly<User> +// All properties at all levels are readonly +``` + +### PartialBy\<T, K\> + +Makes specific properties optional. + +```typescript +type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>> + +interface User { + id: string + name: string + email: string + age: number +} + +type UserWithOptionalEmail = PartialBy<User, 'email' | 'age'> +// { +// id: string +// name: string +// email?: string +// age?: number +// } +``` + +### RequiredBy\<T, K\> + +Makes specific properties required. + +```typescript +type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>> + +interface User { + id?: string + name?: string + email?: string +} + +type UserWithRequiredId = RequiredBy<User, 'id'> +// { +// id: string +// name?: string +// email?: string +// } +``` + +### PickByType\<T, U\> + +Picks properties by their value type. + +```typescript +type PickByType<T, U> = { + [K in keyof T as T[K] extends U ? K : never]: T[K] +} + +interface User { + id: string + name: string + age: number + active: boolean +} + +type StringProperties = PickByType<User, string> +// { id: string; name: string } + +type NumberProperties = PickByType<User, number> +// { age: number } +``` + +### OmitByType\<T, U\> + +Omits properties by their value type. + +```typescript +type OmitByType<T, U> = { + [K in keyof T as T[K] extends U ? never : K]: T[K] +} + +interface User { + id: string + name: string + age: number + active: boolean +} + +type NonStringProperties = OmitByType<User, string> +// { age: number; active: boolean } +``` + +### Prettify\<T\> + +Flattens intersections for better IDE tooltips. + +```typescript +type Prettify<T> = { + [K in keyof T]: T[K] +} & {} + +type A = { a: string } +type B = { b: number } +type C = A & B + +type PrettyC = Prettify<C> +// Displays as: { a: string; b: number } +// Instead of: A & B +``` + +### ValueOf\<T\> + +Gets the union of all value types. + +```typescript +type ValueOf<T> = T[keyof T] + +interface Colors { + red: '#ff0000' + green: '#00ff00' + blue: '#0000ff' +} + +type ColorValue = ValueOf<Colors> +// "#ff0000" | "#00ff00" | "#0000ff" +``` + +### Nullable\<T\> + +Makes type nullable. + +```typescript +type Nullable<T> = T | null + +type NullableString = Nullable<string> // string | null +``` + +### Maybe\<T\> + +Makes type nullable or undefined. + +```typescript +type Maybe<T> = T | null | undefined + +type MaybeString = Maybe<string> // string | null | undefined +``` + +### UnionToIntersection\<U\> + +Converts union to intersection (advanced). + +```typescript +type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never + +type Union = { a: string } | { b: number } +type Intersection = UnionToIntersection<Union> +// { a: string } & { b: number } +``` + +## Combining Utility Types + +Utility types can be composed for powerful transformations: + +```typescript +// Make specific properties optional and readonly +type PartialReadonly<T, K extends keyof T> = Readonly<Pick<T, K>> & + Partial<Omit<T, K>> + +interface User { + id: string + name: string + email: string + password: string +} + +type SafeUser = PartialReadonly<User, 'id' | 'name'> +// { +// readonly id: string +// readonly name: string +// email?: string +// password?: string +// } + +// Pick and make readonly +type ReadonlyPick<T, K extends keyof T> = Readonly<Pick<T, K>> + +// Omit and make required +type RequiredOmit<T, K extends keyof T> = Required<Omit<T, K>> +``` + +## Best Practices + +1. **Use built-in utilities first** - They're well-tested and optimized +2. **Compose utilities** - Combine utilities for complex transformations +3. **Create custom utilities** - For patterns you use frequently +4. **Name utilities clearly** - Make intent obvious from the name +5. **Document complex utilities** - Add JSDoc for non-obvious transformations +6. **Test utility types** - Use type assertions to verify behavior +7. **Avoid over-engineering** - Don't create utilities for one-off uses +8. **Consider readability** - Sometimes explicit types are clearer +9. **Use Prettify** - For better IDE tooltips with intersections +10. **Leverage keyof** - For type-safe property selection + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3067eb3a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Smesh is a React/TypeScript Nostr client for exploring relay feeds. It's a PWA built with Vite, using TailwindCSS for styling and Radix UI/shadcn components. + +## Development Commands + +```bash +npm install # Install dependencies +npm run dev # Start dev server (Vite, accessible on local network) +npm run dev:8080 # Start dev server on 0.0.0.0:8080 with hot-reload +./hotrefresh # Kill and restart the dev server on port 8080 +npm run build # TypeScript check + Vite build +npm run lint # Run ESLint +npm run format # Run Prettier +npm run preview # Preview production build +``` + +Docker: `docker compose up --build -d` (serves on localhost:8089) + +## Architecture + +### Directory Structure + +- `src/pages/primary/` - Main tab pages (home, explore, notifications, settings, etc.) +- `src/pages/secondary/` - Detail/modal pages (note view, user profile, relay details) +- `src/providers/` - React Context providers for state management +- `src/services/` - Business logic and external integrations +- `src/components/` - Reusable UI components (with `ui/` subdirectory for base components) +- `src/lib/` - Utility functions +- `src/hooks/` - Custom React hooks +- `src/routes/` - Route definitions (primary.tsx and secondary.tsx) +- `src/i18n/` - Internationalization with i18next + +### State Management + +Uses React Context API with 17+ nested providers in `App.tsx`. Key providers: +- `NostrProvider` - Core authentication and signing (supports nsec, NIP-07, NIP-46 bunker, ncryptsec, npub view-only) +- `FeedProvider` - Active feed state (following, relays, pinned) +- `FavoriteRelaysProvider` - User's relay sets +- `FollowListProvider`, `MuteListProvider` - Social graph + +Provider pattern: +```typescript +const Context = createContext<TContext | undefined>(undefined) +export const useMyFeature = () => { + const context = useContext(Context) + if (!context) throw new Error('Must be within provider') + return context +} +``` + +### Routing + +Custom stack-based navigation in `PageManager.tsx`: +- Primary pages: Main tabs rendered in left column (desktop) or full screen (mobile) +- Secondary pages: Detail views rendered in right column or as overlay stack +- Uses `path-to-regexp` for URL matching +- `usePrimaryPage()` and `useSecondaryPage()` hooks for navigation + +### Nostr Integration + +- `ClientService` (`src/services/client.service.ts`) - Singleton wrapping `nostr-tools` SimplePool +- Event caching with DataLoader and LRU cache +- User search with FlexSearch index +- IndexedDB storage for events (`indexed-db.service.ts`) +- localStorage for preferences (`local-storage.service.ts`) + +Key types in `src/types/index.d.ts`: +- `TProfile`, `TRelayList`, `TDraftEvent`, `ISigner` + +### UI Components + +- Base components: Radix UI primitives in `src/components/ui/` +- Rich text editor: Tiptap in `src/components/PostEditor/` +- Note display: `src/components/Note/NoteCard.tsx` + +## Key Patterns + +### Path Alias +`@/` maps to `src/` (configured in tsconfig.json and vite.config.ts) + +### Page Components +Pages use `forwardRef` to expose `scrollToTop` method: +```typescript +const MyPage = forwardRef<TPageRef>((props, ref) => { + useImperativeHandle(ref, () => ({ scrollToTop: (behavior) => ... })) +}) +``` + +### Event Constants +Nostr event kinds in `src/constants.ts`: +- Standard kinds from `nostr-tools` +- Extended kinds in `ExtendedKind` (polls, pictures, voice notes, etc.) +- Regex patterns for content parsing (URLs, mentions, hashtags, etc.) + +## Important Files + +- `src/App.tsx` - Provider nesting order matters +- `src/PageManager.tsx` - Navigation and layout logic +- `src/constants.ts` - Global constants, relay URLs, storage keys +- `src/lib/event.ts` - Event parsing utilities diff --git a/hotrefresh b/hotrefresh new file mode 100755 index 00000000..d11365df --- /dev/null +++ b/hotrefresh @@ -0,0 +1,11 @@ +#!/bin/bash +# Restart the Vite dev server on port 8080 + +# Kill any existing process on port 8080 +pkill -f "vite.*--port 8080" 2>/dev/null || true +sleep 0.5 + +# Start the dev server in background +npm run dev:8080 & + +echo "Dev server restarting on 0.0.0.0:8080..." diff --git a/index.html b/index.html index 134b1516..1726b4a9 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,9 @@ /> <meta name="apple-mobile-web-app-title" content="Smesh" /> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" /> <link rel="icon" href="/favicon.ico" sizes="48x48" /> <link rel="icon" href="/favicon.png" sizes="256x256" type="image/png" /> <meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" /> diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 9ac317db0961291329665cb9faeba4f7bb430b22..a332590d45c016de1109e4a6c5627ce797382dce 100644 GIT binary patch delta 7362 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 TLnLum#Dbggq8yi95a&MtO4r7g literal 9266 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 diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png index 0342f4e50c414b24b2d9fc3662006b39b6d0e0a7..296ea512f3e40dadb5b7a9e36f7f35a9cc84458b 100644 GIT binary patch delta 6293 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 delta 6110 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 diff --git a/public/favicon.ico b/public/favicon.ico index 4b4ba5ed25b835958d087d543d0bf4cfccee3caa..6155a3ddd8b606664c2e7409341a481d4d8c0100 100644 GIT binary patch literal 9662 zcmeI0OKgon6vyW(ZbfLlYE`$Sje56MjgSxvArd4OBp#Q<N+Q-+G!Y@hLP*4-Nf#0& zu@Mok1tKg6VbyrG76c25ctyE>|9ih_zOTLC<F-xH#+>GN&wMjy&YbVund!_mCdFSv zgW<ErwAC3?WsIo@s!SGCpO;!=W-TmGs2KU5xm?b`G-wQAG4(S*cA+oqwU?vkgZlLL zr@DDC7UKM*u$c+P{8+z}<;P<e`ZIhg!dL1}LX!CFuxp26zEt-Fsv$1^GHhBwWA~1! z%d6Xjw(;+#93?F@CW(JGc5fpbb<mc9B=KLsE=>N?unk=NUM9U<xcnF9gyz4SKP15u zw|)zW=s;z_?LTh+49o$|32^(T+dr8GePpEXpRK%+&a{C!?|)Dm4A=R8p4jw1-5+wG zoG|{^*y&s!=<9#*{0#Q_;TnF^3C4}f|2urlP2gv+<A2PUQxc3j2OGs0Cc2L-1%Ju| zWwn9gzX)Mc>iJ(W>0THZ%m1J@(0%VCgo(z@g1>8^tTxbheHFr_)cCt`v=!t#FrIzz z+Sa0fL6n@KF?27WO=C<2-8I8R@o#~AJm|Rlqx|SRUUUsmZ~YvUVT?Sdy%y}N2ej&8 z3FzJ%CVyzy2zJ~g@$3G*!S=C(+GlhPJcT539HTzJLr|x9MuUDYh?4I#u7pzJm%o>w zd;dhReKi-fC!T|(@(shoPz|HN`kFv_4=7hrqV=e~!uCs&f9p?U--Xj~1X`dSG$%_D ztts_83rFDu+=d?zC-*R@fd0fE=X)sH5BXx(J4EIG746#p2P#?{S3qlTAo7OJjgVi0 z(hwE@G1{9z@6liRK*v=u9d7p53H^S<KIqwhDM!Vxy?mW#vj+VPN|pC?*a>!>)>FO# zrLGsP3+-E4<97RtoBuDct&zRQ0<_kv)_>giU$D6hE5OEX{pfo*09wDwX_P334`4ft zgPzz`&@(6}J0R;rIa>|dkFA~b7+3__=d^d+0j-ZmUbzjuAC|#*$oKUR>eXouRKsRC z1=ryr$k%<i1e&|+p$hu=@%t>LY<;1XSH(C9D!sDKUWv!@Z1pbXARqFjIifrz_ESpy z&emnXWx!>?Wx!>?Wx!>?Wx!?Nf6V}AY#p;rbEY%>Gc~MrSw6msPEX%TbtzuRO6v=> ky3Bj_>*c1>q^ZxECR2glYr0K_``h1iu9^3@c^GQ_54J}(e*gdg literal 9662 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 M{UXQe_Agxj8~PUURR910 diff --git a/public/favicon.png b/public/favicon.png index 47b4198a8796808610b71a4b5e467943217a3f09..14c6f02711247e31488d49662fc1ba4cc4d0cdc8 100644 GIT binary patch literal 9904 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 RIR?53bTkaq%T%4>{u`DXohbkS literal 10394 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 diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png index e2eae2f2fe7ff5fdb275a3aac53b8de3f08bc077..8ff0077a107c3525006bcfbada23013e8e6cc098 100644 GIT binary patch delta 7495 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 literal 9359 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 diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png index 535d0a81b94d89926cb8b14208ba333ceeb4bcf0..ed0b25676d6ac20006a95b0ad1fa01e669aebbe2 100644 GIT binary patch literal 16033 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 literal 14946 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 M%d5*3%a{iJUtahgxBvhE diff --git a/resources/smeshdark.png b/resources/smeshdark.png index 2625743dac2926d781913acc64d33ae4ed430937..c3651c5a8fa314efdd986cb76b0f6fe5cea18c25 100644 GIT binary patch delta 12898 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 delta 12844 zcmYj%Wk6KV_deaV(xr5RbV-Qh(%p@8Ez)`ElyK<|SCIxmLTU*`X;-=%VM%Ea`0wZU zee=Kf#eH+mnYm|Xo_WqQ(-nh$7mc35hCcO<oe6-lwY`ab+@z4kAe&2%nbwq;JiU;r z{`hnK{p%^j*k1Mc@Zgj6^`oedFCI^iXYc84s<yWD>+8<-Wo)V{y<#NBfS#Q@+OQD$ zRTJKo9uuJR$myTlYfHr4@$qucEa`0Uv!33oW486#`(B*e3l5@{ZFxDG&Cu|Ni0}y9 zmP=qZs)sjmU8H$+6}Jad`L3QTJi=y`GJ9p;+hP&<!uQtN4fv<gWd4)gu)+*_W3I6q zE>g;Kx@7*xYG(E24f$j#VQ<8ve@zp|3n8V637X!Tx3{^QYhO=)+xsv5>989-kBFzS zUi(S1USHb+aNc?u_5Jhpcu4#OoL=a(Zs!AAPnZ8y*4}qLU`xm!KSUQAMkI{s%%Cmo z=3IxGhu;L>FCv*A>Hf^(o;pX|5&uNqjYssZIA8Nq98K)?1h(CYj|r&o3|53mT)3wk zGhS&apUN6t*Y`;3E(X0n9uGVlcrN(mz5>B<94hz*P$YQq7BUdrz{{7DcVN0b?I8uE z9NV*km#K8|(4vkZb>E)`dX&AECHmkZ?~d=i@bJ;1{O0f<$&y=CA6EV2McZAa#7d1h z!Fu*M=Roa<VR3CgE~0_ji<NafBBsD55(t01&<IX_E!Zn4AujpPuawz|1c>DAQBw69 z?TV<Do4RL=#1`^*63Q|l=!wl!v{MGAYvW%s(c{zcE!%$v<GdPgHG}EJ#ck;_j*f|l zby@H)?h4n^WxkKssNMQFr0`jPc&+jyPP|H*)OL|ZeaCi*YLarci8_D9uAYOI*sQ+0 zXZ@wLxkuHuvAJj6h4|MCyzpJ^;>(%PXE9)0R??EEOM>59Xq^j!bIhCzm&clb$O(1c zhR^$MH4ROkLt0VCpOIdj$3Fe&>YT-4!&Q~V5#O=hBv$;+_}jiNdi8E{y05l8j9fi3 zxvv+Bh2|qtFF4&T>n>)=6#SssBEnAG87Jy9KWN~_<6(r{`NEY&<*J6t;#B$BMT}WO z!g<<_VFNlxJ^8*$r;qI3^{u&p#9gn`IJf_!pzmWrTkpt1j(TlwlMXG<CprD_u!x05 zakd8ehNY$4XA%m&Cl&z&497Yn30Rl1HFA<3BMAoH!yk?Z9+C#nJ4;&b*Q^=>ownY7 z%OU;&<s}?9cWj_CNlk%|>FxhT#{1*=6u++3?d#4~yeQVYm#j)_#!Qk0@B)|L2UE{F zt&ev3%Ji%RIK5i^Z7&ng*i4I0zxTG;>9?DPbWg*J@Fnp=&cS!Ucu?f0bhA+W@H=vq z1KapJ0b8cmFSca8YOZ2~7BS#)MMruI9T5|z#}!LUe9Uu>Alw_9Y+9u4B2o@o<B>~Y ziJ3%3+HO_HXiR20=oOa)2>f7T9X}GZv$LEE6hxMsX}=J8TVQgORL)VZK7yI*@xYN1 z>f<=lzm?(4fKCS@5*QXjS6gItw#OOu{4$>bwX}E0R4DYIFO>`#4*aem$p7W(<x5v> zzIxrfE}K~#kK~E>k>l!~XmP^nt1=49f~tE$<>r(Wb!mRZ6p>sZ0G)U7EV}daU`;mu zws&g!uB>NFLX|H=O~17TrTLd(o9wDDd)3Sz+uMdM>%Gjh2yO_E0ueExrTI=^;8lsg zGQPAboTmf;o?FH8Eds})V1Ie+ftgux-1yJOPr@#B%i;&*Jk0p>r(=zHx3HoejQZe` z&4*S;1gEAQWgnJU0Z8YY@uNY=m$z=SK38KY2U^M!JiMKy+GE7)KoP>XBSyoQ#t1L! zT57&61@}yhq|cP*GmpHAjH#E!n*9`gZ9h?7;-bD8)$Y6!nCvI~yZK0z7&f*3F->w0 zniJAcgB_?8%$j}4=QxXF+?TLy>{f~&UG<iV>xZtsOqn<X(3a}Igrhz(x|;!UyQc!_ ze}pW=XBZ4F*s>h+*}t(7S^J71%6u!FLjFxt=s+c;Num60D*Z7l=BZyWPO0z@n>oVV z0W>p9Sjb$abk7GqNXGJL)p&Q(B^k_{u}Mhh>Z9Ah!`wk&D!4nr*rZYGow@OS(XZhz zM&nw}(v81xfpbSgPvRGqD$0y*TQQ`ci2#Dl>cO3}QoxzT6qBgPnlX2D;tTK14^K*h z#<$}9hgEDOqxgoJIFX{vD<4YDqcA0eCS-A46Hfzj-4pBy2`BRZW#NHXv3V$%0@8V= z5M=@C6_=qxt4b+9OujQ>ut<L3@XfeTAK>ba{##7H3#2*+Jdlr!8U!_qg{eF$OsfCj zkqOmFcpF;0#qdX&b7zyVMmqd(ux6UPZ1gFpJs6Gt&BEoA&MoZ3Xa!8YX`L_rH5Cb8 z(+kC3Da?B)n%Pz6`KnT>#wNL&{M;5X{(*nmK}LxzVm|6lz#457CFVNb+3>(ynjFZt z^SFhwS^{h3^oT4Z0!cgNLgmeL^5W-?pqYX#+TUpOHsso;PtCTfY8Fhb_u`UK2um`y zBL<^av>y6B?fw32ol!XyG?9?%Qy+2m%KYCiYY5S?L+C{`ThX`w{GdBT+a~M0-J-gR zHM3Z#Z85N-@Lfb#hSWId5L<5EDzI`nh@q8AVggADzGyO>Ze8jrpQiqV5}Ok4(bHG= z)gwZ!gMV<!{1LXz{x_yir<A1Gzjcrk(Egb;hsDV`caje6Qw4XE%~7WUR~i=??u)`{ zQ{&t;oGl*u#H%D%))Bic&bsd}`StUwnCH252`9L9&HBKr<2cTf;hd(}J8@q$SaIIt zMz;Z#d!h=){gne@J-SQ8W4aieUsd0EQ<p6?*^w1}|4b~(J8kK8PESnpWXMO2oIZob zwv#7&!dcAV6<oQS%J5hT)c!UNA}T~zM5M$dj=5X2`IkVO89x?ZuHviR80F3d<T(Sw z<e%<CN#VCL&#rU$`Kip9%BOaCnf@+OA^3~{jMsvmRf)L6Sb};~6)7V{5ISWIYu3(j zP11Ry>5F#0l#gud6!0CKYR#{35hp66vNvlM)x8`39qAj%Bm+4O3^)rhE&drfKNE6y zv#bWof|+j!9)p^=?N-<Ye_dc3Ntbx8Z2bu!n0aCD?Ct}0#3vU>NVJtKTr$*)+Ab6a zE<SIYs@5e6ww4dYyAVi_7^JL}2VoIG8OU>FT~z8P&|ec-f4J!nG4Rv-kTOd78ePGG zIHe}!Qd-~UIoH_Q1X)Otq1_w2C3`?!YO<-rHWLyk2X4=GseHjn6xa0H2`#B+r->tU zc!kdBwc3U75?aI`jJM^jF-%@d_+P(Q0|uav3X~+Y(;sWK0}MY7lVG^Em8*nq{$9{z zOo$ZeBej#8x2^U&W@?D6q<S;(>=`5OwW2Mbqbe^;IWva`?Y&A}lRB9sxO=U1mA0(O z1cA+6UT|2eD4-q%d8doRMB`<r?bSBwVS@c5dNWix$nMjy)tuKd46|#(Q(7ye4cOzD zw4E&1Xo#@mh<qPNkJz9xhn09MjY0?|m|9Is%#0{z{Oe;!%Ew{$iHGKW7s^iY4AbAx zG7IjnT_;Hz>Auh@9SLOzmEx-j)s287NW8FnRa)qN$o&PI-i{yStNK9hJxRixvtmAD z^?&Axh`}@!-O=7SeM$L~sc_Q>09-@~{y@7g*JfRkr|F-|I)qKvY^-dluqQbmGT*x~ z=BLMN>!?=yer@^ql8{xzv5<qQJ?neFqs-9R-1zEl(4Sj%Ya#TAgM9PUxO)D|XXt58 zkF;!k?({aqOFrLnKT`R8RP;coo#5i{K&LW@mbec$mu(g1{4lFjX^gDv-UAM~@kjpT zpv^{X)t1(~75bexIWN;8w6a5`#MA-(`^3!*kMCa8p<>i~eH%)mG(sPa%P{uTNm^q` zEW;T+hR|T3ie9BJ{HC#-^%K{uqLlrgxB?ggf1&?!A+L(sQ|YU)+?__}o9Qxmb)iXf z7;Wqg+%Dti>vCyE!t*PuO@Y~e8z!Gun-~*5kriJ@k;2jCNLf?`xDh+SoZX!(99QRt zw^KvyaS2yiidmd+<IU&k22IlW*2E5&K!xHbnvK$0%>;A|`j&4qgPI95_XfTo`i%11 zS&Y9zcW36qQ+v$g#2;DQvO=HCxX$W)zEdww&^al^{(T}M-_yCNjQ}d?MCywfca>A! zK9!d*80HdaG*-B~ZAJ=V%p2JV5}F70U*$Y*kPs{8(La6hj+HvjG>fPgFPQmMP^A7H zN!;LQ<Y_LKW9U&eUW8~(W!csXLkk&Sm5MUUtkC!(%+B)nA5xGNb={Mi<``bzezMhM zkT_AWeV#ZWVRCsw*V7E(NBXwo<o_loR&Tq@*Y`5yUm9>|VSi~!RzbkTW|^9+|0&ll z^iE;W;T1J;jUrn($Bh9N_KnR~oEg;*4E|SE=nc~6+&H@dx0=F<l~K|zTS6_d_AQu9 zp^iz64qqI<5eIj&|H9ZubE|o$Cu8$^Xjc-O{OC)?<iR^8f#|=!z-T=ynF@lIR!imk z!=(TR(!)#mqn&S=KtWkg2jK&Wq>hdO8n>}l%6F;ar$0K%1_SK^T&`#O<b8RO?`;OW z&!qAT{zM2oRsKfvxk$Qe-4riJ`#I!+OEA^u6CT#H&@Stlrr{4Gm3^DPQu)MOpg85j z2?1vKL=|cLF05Qenm~FCLfu4cl>OJ-N&aDyJbZb;1Gc98G2DDa%yF}^EGFk|iC<bg zcg-)qkWmA%QShw`NqQQ4qrbHLdpSL&{d`Z@oWJd?#7~8_y)ET!>5_f?5v`fVM@1Zb zH1Z5lgZK%%v!xSsSS=M1?ch71H!?a2_>54d>i8CI&^0?YG4K*6Z0f}?4{245t=f!- zgjl9H>8r`A=(KKukGHl_dobSq=6QqxP1wN^N%T?rTlEi{Eak_fAf88!6*JeU^n<s3 zd8XW0wp1Zx)%q+&KuG`)Y^fDRDUJP7u^2w@7JY@5(81Y|^Jel0`|Lck^QUHjWpzUO zA4M7%SnB?E4oJ{5xVxN7Pk}h5R}Ob3bNDjq>PUj$XmO8iCU@tMw?&h7Ujtve4gG5s zZN!9bH;7%>Lx*#}!mD?BGlQ;vU7w{@wRh1CN?!PAY$ym?op!KDsAo%m&5LuUx0WYW zP7NI1V=OXsJ+3^c#{(5y5_XKgNt<HYxhP4VJ2dDr2gncFnB!nM6%9P4aqbo*&lPIY zF;y;;D%vAchd;+C4-Q%<lh3HNetya*_RG^i-So>#_3!26I^X8QcNm2RRg`MtduKEU z2|5P<sE6q$ptJJ$=0V<&Y7iAyX4mJ$|H#ugr?zSt5%fvvZ6Khst9(DgL0HA42oV*Z ztALFck^nUPun#KM{yzen!^9iPNTYd<|HU4`_i=F!Bj{d4M<#V^0iE4^f4!Bi+8cJD zgp9>oT_WEqmJJf^1+RIxKlsWqHXh7J`bC6`jUVeQ>_;Tk$lYYNKPa1D1lme_g~`t- zsz$s7O?hK`_RrIsAaNfk--FKu0~yV)_FMjtlmN}084i5|dZIA=%FWK%BiFOEv`2RF z;<Uj%qCcwe$I0XLkVh8@eVQa@J`uGvvX2f0rst|lF&;S^OtSkFGuP+IM$X)c-k#F3 z+4!UcF04RVLGiZpXlQ1yLq)}dhD8GNkZ%){V?k7drp!ElGQARMRi3u5ae)xd1(DTQ z9WcJf7Ii(=Rj$_ij@6Adp$b!F;khZM_ei7REzCrmB?(r%tZ-rYe!!f!9t#;;&tJvK z;G2*Vx*?bP6vK+fw*6D(2Z?D~sy{0bZbGz-^Sq0q4uYH6u|r?|>GCKe7v>C)Vt-b- zxp#<a;z$#__7B#lEZ&;g7YQ6krjcQVVt^W+YYP>u-O$nVXfWHqF(!5Rv{<@`o%f+! zoa$zZE|YW>JyR=mz|)_`$yr=PnxoiIDM8fhANQ}RXGxm}*9uQs6~XupukI9nU>Z@M zg5R<Z)b+Z5dG*7lG~vXAncf~I1p~l;0;1$*VhwfsKs`hLrUtdG@5;T<L%?KEXP^wj z`!_<DDaDxL!~03%8RB?_ntrR-D$>HY5wQfnb9O)K49qmN%6Dmb8%-xYVg6*y{^96P zKDj=#<f}mOgJi~7@@o+ux#GX6SwbuL#+9m5w24JB2u+Jbd0#x9ESg{bVVho!4F4jR zz2Cf>!>3{yXx?s2jc59$CZLXMQPB(3(42W-m7HIRJ8-oOii8y`mp_6wo>QMyVm^3> zSX}Kz4#ED`{vmSpV0czbH0Z-a9PI&CohDz~gP8%%K_n!F?L(FCnEoBjhdWhZC8S~@ zW5guapf^ukWqfZKd^cJ?^}qF`p*5kR{!#FrZE5Di@tfR|xL;xex98o@)f>+2vSqI? zd*thmSKDeJ0fLsJqYe!4ur)TuZ)L`taD{^=Ds#W0$)`yv6g_xZ9~rm@t@X3`a+Jgp zY81rCWYyNP8Opl%W6pNHH6PEX?{%JCt77(r$Kto%>DYZ~jP&kQ_HQ13vV_Sh_}an2 zPD+Ab)InT~UsPONkY7qfRFvOQ)J{U!LEQ1Rl!#>JPpW5Z4&ss`!j58g{F0K6!YFGY z2Yx$ICsBSe5n<F<QrtmOR5Fu@nv+dXLexRRQBsm$(oVt-6;V=(U(!)Xf?rHX$lgv+ zL{e1DP9oEU+J-<xL`p<ZSX5F(G!sre&E_a9<RIiEA<Qr7@SmTAs5rlru%Hybu!x<w zgSgadaeFbz%nllD91&?z5ow`jVp?xJtQL+3)yzm{5nK#jq5-jXv&^r|T!0o>Nx?8^ z;cz*)ox!O4==3;!lo>OY35%@1_GO|le)1?&%y2E2f*w}-2IZ+F!vr?ze#ajl4A~zv zFF{J^v=q^p!gv_3BGIB^(W2O}@R_k*{V2L$k)KKSuz!LG4Ky(d>@D4`az4xG?yR1v zUO%iBi_HK3o=jqe+)OeBLcq)bpE^nm3PTgeZN+KDY8{qOz`%@*x^b4bl@omaykWQW zIpzdkn+zqWxv)j%7W4SPki-I`5IsgZ`x{!Bvp_m_@$fdbZAj&|kFBNB==nS2A3icN zv$Iq;c`In963<4ETMq3y)*)LV$gWXLC$!%=792{0ez|X!k?yATmB3h(R@8;BEb-XL znx#qi83LEAk<2tJHxBL&*9gwf2DC9bc9FlDK}|nCc#*`<{2^R)g$L4|pAIPvdea3d ztdPtR&WSu%q;ITFf)~TB;KiU*O=YAH(@BrEV-xvg&3H}l(;&Qd%62Ihj%%X*fbKf% z1L7{~InW$4ZjwqqfG#uX+QG_V?W0v25blY2P=s{jv}K#@Et?KRn?O(BIfGbQbEw7^ z)G{>SlJL9uv$EKS#f{j@G<qu3(9?i?8HwsfZLd=jC40sfJ7on4u4x+e@Rh}B+cDZ@ zSU9pR1KoF0kV8?JbYxho!-i`(Ya59{bIt}@>l-d>a0X!@B&%IY<8N*WeH1<G2L9}1 z;l7)TT$@}5P;b*WR)oETzC^NwBV&9~_Q&?Wcdn-of+JYlNzDy*$K=EqdROwq_}gmK zNTddfy+V|K>oW+($vQ@<aaZ<TBw^GDi+$5XEl|~L2d|8B;0Yjl?u`Kfot!y&s^}$r z-6v(p0eT~kblkjtX*yvzL?R>Ql?TIc6>$f6a1?7Gf`$%<lc}Deg|KG!F{3ZCoO^6w zS$b0)=Dkhm)2bjd%^bfXm{?dq6l|IO-W<UqmKzKe$ZpY~pPt@<j=wuEFdwo6&#K+# z!6J+Z;<0o30x0E=zEhi0VOF;s_kdd(V!lFvit}+D;ZhuJ^qJagIWIg*G#QS)A2^nh zm+W7-zhcnnoqnFnI;Xg>1HWkVVOp|4Fv2dYo_)BU{kJ?#D*5!f6D9;zX@yvITOg+4 zY-_LeD{GCLI3R2%yldECYPFDbZ;c&W7cFuk-@KrV^N`MYfk_MWS+%chy=nVL#O|H| zI2WxggRVm&^DwfLJ={XSAwX)5JvGaZzA9$?DZd|yJl9?>RNS-tD_zb+Lla!j6H-ct zRfM&~yC(#;fr|cU<MI`3sW>bMwXPqJyQNt3rkGu+(38Vn;@m4<Zg)M@3{k(X;t)tE zf$zbKp(`^RQ4mk?Zbr!M(R9V^i5>vQ0^4}*e8lK5``(MGvvXF%c&xLC=8XQE?cNjt zS*;%Jn@mQ;OhtwoQSX;PBhZVq&br@N<a<Wlu`^D_(a&<}>-^F(hw-goF0s1SP!Y%B zqHTg3m_u8xZ<cVc<xWh%g%WmFo@yZ&kBZf+N+0lIA`PM{*{t}h(o+T*h=4jg+`5fd z%hcD5CrMOG$6`@Pw(j0|`q|#hg+CWwO`qr9p1TF#Q4z$3v)yA|W4e;<2mF=Oe}Nz` z_|$WW{uKT4ls!1kW=$eFF;0L0l?oPyJY1Od!>eV&_ILq5U00U-D0=q87CMP~12r9C zbN`@9|41RIxZ5S;yB0auC7}H|qG!OT4*zY=`TOc#^Bg}(SG-*29)^agU;&K(QcL9U zt(a2<GohvLffVDRy*fY!n#TU+#?_yG2Qt@^x^hOQd4<_|JB7100{;>Wzu=jf^?^^r zX4hKnpJSSI(EOJ`zsWb$NCVfg*&$vLtad+k9qqtT<_@b8nN$ERVg^=zau4H-qrb_D z$W#O$k|Y?`_FQ5Ke7snU2$+4I%m7<O#aN*r@=aenZnL93P!746yZJ7baW38MU6}oi z3;fLOvu?Y5mF)V<(sAAvOo*=$&aW{=q@^O#&&3@^Xo-cIg360ph_$8pE#U(1f~t_4 zqc`0s4K%(DK#p<g1kMqaLjv_Vs~+UCO<bwh;y;JB?QovuVk%m(d+!-fyz?Z9H;ylX z^P=`vMo&wU`$*25F&(9Xvvw<D%$^0Ta!4Tq!QHK>HUAx&37F^X;xGvwYDHPI9Y!4` zMN?pA6sCZXrX-0&qDx@9cUHUowOcD>pPrY7Us(d!HI6ut?vy?TqOn>Y91NQ(7p?<z zOMH(5w|(euwnL--5S@Sw8_)Nw_D%mYQkTnJEHNGnjO@0OUH*Dx!|b|@F%agxb|!E; zjX<~DvKPcv6salbgRN`xC?ZziK)c&l&zR_eA=StW%Y&Njoy5G2|JhXN7;^y>mzCY$ zvprfAJB6Kts2SHctUZtX>_6=9u?RLBNxnpcfP~&-EWY7z_1djA#rsFuP8~#+hoOmU z&3W<nSY!WC4r>l;j$)2Bh*USbqYB`2eXYdjX<K$oe6VZa`bCpj3*>^zzEk!T=3J@h zrKKxdD%OLJ^6-`b;@_9vltiGT=DIsLI3HUk|8Z407!b!vWBby%STPtrPjxLp_hNJG z$k5^BG!7mvHLCN2*@I3{0`~@6YP2Cgml@WinXs$dE8FPs(5BMC#UdRxFNCY;?l>$l zR1&0Z82O>=RJ6)$yf=K9Bp#m5)w?%R44-A)C^?GIRno*Bhn|zKaRF-y@I{S<1`#$Z zI?x&2`OS+gOYh96X7KNkvEY<dE2<r5j0tE7R^UT40&^}+lxnjN-UF-bzi7-8)hf%6 zDgP214C`<^M&sD=-V%xI80kUR*w6Qlus{#Z__!Gwn36MOxJHF=|IQf&h94y6t?qF& zbL>t336Kg4PjnNX1XkOoyr~el8Y}U$kqt_NaTgD3VP2N(dmL*+a4`G9s;(^s#4bh- zD13rE!dM%*An3lyz8eUZ($SYkxc_AZA98h`y*`gm4C-rpB4RN=b|Na4(5o)pRWnea z7SaZ*nWo!Ola}bcJWw|~hx)a^@F|zt#?)n~zh}3vX0mky@8RM1MX<Cdm%d?`S9>ea z{iBWO^xqLsIgULJu*TQM?mNS^9^de&i)O2D$9<cGd@1Ng3xQYat@R-^bNnoqiHS(m z&PUxFB~JlT-Q{$ekZ?8Q!yd;LPfDCn9UQRYOZf)!5fj9alm)zux@ur4XMOM=(92+k zMt3DmJ~to^<nwg<?NwVHOvRsU{!{FIK4y{r76Ge$gpwsKC?P|dzL-R&xd4#EZy#%; zL;=#rusr-C^aBde)y!!{2>srdzwI2n=~YrPMGT;3q#>0C%SJEQGjGS_wfx9!z+)<O z<_@dTMybp4JehSPOuw+bLaf?Xhn7*NJ~$;!0Z6SM*t=m^DVhpK-nLy=1S%-~Xro3o z<J62tLkU_<PGl!;&+AbOc_Ud49^Ncr$QTm;v-6izzjJ_{a&ERirW{tRCTpH@DGgCl z8s%x~>cx4>A5@I1?xvK;yD2uGY1IBwDVAj;CsDF!s`;i9md0Pqj}~#Xm?A}E-mFIg z#PzzJ#QBLsXsY^v>{LvW<ivbpf%IP8RH>AaAp7fl=;ehx%f=}b^PBzJV016VIfv|r zdgu0I6GXU^-n0M>NQj^1Ayahd3S8WSW@Gd{(^Ujl5n1oOd+Dug`H~a8(x{!!dbIpx zI(p{H05KQrNN4@0ErWt9joVKC8Os-dy6#m5jq2$@?o=qjU>rN;s*|Ci5?^9sikgGk zQ;dJ`|0P3&>N=ng`H%J_NO@#4mL(zkhiCfm)1{75SRZ!%F&@HlO!=-MnOHE4<D1;Q zw5fiB^wUnptl!#~OyKOE-tE~@2Nru7Clr5Fl8&694sRaaj0<mz-u7_>l$K2UwO%to z9Dd&QL_{H_KgZsO)?zqzg>~z&`&)7i+uF8C15UKXFU2wC_D9-KD{gvq8sr0jkq=^z z3%Z?}Dco0-SC2Vej|0i3;HTtgkxwp<H<B-j7_Clj1FFt;v!|`ED}(nHL{Nh&eQRgN zJ1$!a7?po+XJg^WZ3iAKxB_d3+9+E}?p+STT6ht9O%8R<i;*UTmaF6b4_$lg*^9bs z;^4wNVJ{SrUHCY^kGHStBc|j@*Dsyhp1tXP?n8b{-BJ>xKG$}~YOg}6)Bg{9Pe+S^ zh%?7DtPq`4T^i@Vw!J!QF&0lGhF#ycbiH8NXd>_!dZu9s1g=KUG^^s@T~;QSrd(0A z*K4s8*KO{~>b;pK#;DH{RY6eo)E1&9%Pb#OJ$ERI{(>Mj*u$I2$x|c6bqqx8by{@a z-@kp%WMu63Vin~7uQrL2dmtuqh~0{5@33dEu+DVCX(b4Z?(x!%qE}8ZR`ec$R%Wu& z%j8dI3LOB(m%6%M?av94U#pTZUB+9mY-A^UjKsib^@cxZ*@wp0^KkXDxzg!=Pp#8I zN6yKXHajvn7+o6nAs1n3rT&3(2_c4M459CEa^<WMj_@ni4e)4fFlt#mg&V^!VVA7; z#+wr%yC38mF9{yH6%mAR3WTBhgt+~18;8LM6TqD`x9g#kUUmSS`qXvZw%r&1?&8(A z{lK`zyxzG>ZkKR><QP4unSELHKA~?fZ3UL^FUP+Z2v29(5I{8)ZTGa_J~zy#Dlq+< zn3~R0i65oCY6t_E&lrdDV86JGcr^u4DR=OFn@O$LFi$#5L5as|{Lt)vEH5}DKRTWW zsCE_{eb8;Pox91CBa+la+{1O$45s)4WEumEZp7oEuMlbQTrMw~lohx*{1<$djgi~6 zSzKDcNojD9z*A>l;^+kf5ur#$tiQG^^fULH9|a+}1IU81us|FE+>h$FA7K|l?uywF z_FS%L5Q^>b(0M`{{I^-6<kuQXGAkRv(6@Wa#S&@_%Rt<3mw0s{!J@Zet1Yoc&o<XJ zAHKz3{A)}MGDLX7SF{?Bk(jF(+L?aDC(Vi1g``PeLk*!C<<UHMgSrSD_-@tA>pw`S zbX&_;b?kwq6<=*|O(_GY!K60A>B}FVHuh~lnI>R5Q9{I7%#8LNLk?$&-L(jSW&X{j z7Qr2O@@ky}Ua8idQu7+WY8|Kv{S>`uXiU1NYDE<{k5!YbLgW;|TD2S?LWggOrkU%P z2J;L{%!{$}DTi3CnI<E{O;VCTLcPRkDV#j*T8_k==j|i?eT~EyHCMPh!Ji(9U3RV# zVj?r6hS-(`_E@1IHxMD*f$u<E|B4jFWt~f~>>SR};1k<7*ljhnf}-CX*HP7$X&WSn z8lsny^{WK#9mx&td57yP;T~dy412@mnz3JBpi$6xk8K-aEv@FM9P-Xui<BePu|47e zsBtGb@uO!TpD^?z8u}ccp11#*YZRx4%|wM1+)Xcf@;Dn69yMkGs|DiGoiQG{_w3hd zQM^d;2Ln+j7!d;3wz~fP0Tuu_u-cmwBP}pZ(?v}$UzluuJlBn&MC_NGx$526&Q;I& zfDF~2)1CN9vsk}7S6F*$TSvvyfDY$||AY$Q_K<@z!)eT#s}MC?^_s!rjFQh;>TB33 zc2W#fmHx4^93>{60a3nc1JK%9;&nyOH<~iXzc;k3-+(Bb5>v|b>1GsV(lbLutM=4u z_V5`<WtjKu!G*WF!ngx;U^=|6_uu718E6<m{<OdOS*+hC3c*Pu-J@uWA8zIogXa4h z`MWn*Ac8INu>%%wBrPW^I_Jo*{YVm>HU6HW2(1{6-e%ixpc4F>ir*9d%?+&d7IPfC zo~c_s>{Du560IEngKK@|cb+M8f#_DkgxSo6oC(+UWfc4V_Ad+ed{;XMh3DSoIVwL= zBI#Kw-uF@SDG!o^gy?8yRfYy@Q0{wT)C%Rd9e4}E3@hjd3dq1C3;Ju&Ng1rz@@&Vp z-OamUJrH=7l=#Z#_zSo)({bmzCFpl98E@K9jQc9&TJS_*?X&pYvS(B%d+}an0?Gv= zIJw;XJ3qs*o39{Wq75r`AfssvfzB`2xY&UX&?y)p>Q}e2SA%;(ymlmWUy~nW#~I`P z@1EMqlUQt4dCk@oLtd)M5&d))J_Emj`M6lj18MMkXaHoaFlL?JE&o;<j;wEuH{W2n zt|XyWu&_$0m0myDPP}GlBy{Z>Fm6SMRKiHxcgCnU7uuLVt$6U0#5*nTRyMa*L|(sC z>Y|p!$eZl%TzJ-Qj0m+t?%0~%(HNJcBP3khyU`l+&Q;(Z%0ol`FuO7BGJj{*ebOa> z%Qe=0X+f4|yyo<(4e~{7N}oC^d$1esX|xV;RkFnJz%NpBC-H6$AW4X^+MF5hYn&M; z>D+qy)@jC^>MNdy7{*40OwYj#oNKL#YVlhtn9+N5L4-zSkj1Ug(*dusux7k=I|$-m zv%S(J%7PkRdpWH34{-s<G~l6*oH~HWXz#}<QKL0+Z*MPbmvw{Iq}*&IC}{zwvmrME z)CB*w+JdeJr?GttJB)meKBT{R-EMTQ2<}Zy46b+N)i<*KslDGH@Ql}0<3GndBSeU- zsxLBj@Dy~K{||c;3E$#aDQ-jO>@=>kidp{+AEkTzC?TkhD1r7Hxz3ye!v%-^tc)$z zFT=Q5dUW<by}^TsjLjqOYoIe8zuf3Rlq$$CR-W~?c{bSp>UaTy1Hv$M=<_3|G0zY% z@*?YX9X;6#7JE%4h9boc9hZ7n`C05VA1(7m>;Vr3t7oNdk|)^UW8ZYg=8r}*?(z~~ z*Yu2QKcz7IIXubisn%|Qmf>ijxs@m$^b7r8PCcpFFT!gy$Y<D?)fXqN%37a28F9Bz z?7>-<je~!2Ycy+7$=-h>pg3Q5Y7e9B<m{YxA#=r2ZnM~O+^$)$@_Ks|lD3_AQHNXL z41NHe7ADT&OOmxWR}?OAdY78rVGo3T*2bzYzax%oW093zQiTG$CgL?c&fLx!NGrAr z$Z4Cu+CK-S0|mF{2ybi&AL?1?7f-=a&b^>DH(RiTd^K((bwszv43WexJDiohHUv!& zA!ZBmaaTIPVIbD5n*8XATxe<~i7)P7XP=UDTuH7~4CxMwg~`<iG!OICbk8POozLt& z_!6IJul4RdU;+fVioSxin?G+DAn1a1+RttT5Wff3>qDB==`0W->3+W!-&~d6fPAD~ z31-0T-s9hoBJEM@zNn}HgBr69(XGurQmagNb%NH$D_m35sbPK`0BsFCT_iYCaEP)x zll`QL><}OAJ4q#PW@Yih6~M5r9a6>U+>MlkCq$V!0^=9>SrU^M)K-|S(9JjECkksl zPb3C(h{pOL*qpn5YnE%IQ<Ya6-{H>e#HZ!9#<Xo&Eh`S}-DBUpc{?6QapiFMAcCT} z18&7dj36J06N9yN>98%^HLL{1=HY8!F!GSFXnmv2XbIrJln?wr%aXDN<w5T#!l>PS z2+*GjotjOEHQV#TpvrDJ%4GB0<Y_c&uYg@#`u;+p!anxku9_!l1MZIh=KFYzgqsXO z-ut2m?Dh`W^c9-GMcH*Lfy3Sd7Lk?Es<Jmpd=0>=rzE2m(*4gxLe7mIym-?n$h(}) zULQ)mR#=Z=@$=pZ{lpEwrUZ$Qz#UisbmoFl^$~>9%A2XH6s6|5<Yn()!Fk#1A0O4F ztFsAB^=-ha)Qu(HYIO6jwy9nM|BaVNeWc`5Nz%Muaq`bF+Ed?|{j~Y3wwap~?D_8l z?5fR{iGgRYH?j(DhF$wN6D|q9QoDUk*s{HX_Q$F}#QAR6mLz_;vuPAZ1rwMESf5b6 zo<#Lg3XXU(yGq?|hQKvvBkR#X+(?m?px0WQa2%-b|GFauQ>!nC$cMX&C*(Aae=~GH zazb@v0n$!(O?mPr75Exw!&#Ue@gS$MZ)p5cENWFlx*R;P_7cpg{*^Qc@0xG>%FBvS z64cI^$Fc{ob$5b?n>kN_jwi;lQY<;Q)u=vQbae2c)z_U)ptf@ELbHzXGC2dg0M+NV zEihhoVz1~Egav{brNPPQ&>`G+lf8}>qZ&~Bx&RfA{gAiHM7izi5#7;Gw`fZPrMt{X zFlb|!)SphsU`CtW>>dTJ{Y~~57lM;R<qW<^5I}Fi(xwRD1FUsg0;3eIz;$>cHR^eW zQ%|rSVj3#P>zb0Bb!rmXkkfLD8zv@*J8)a5yNSAos@Lo$S$wM*Wk!m8_gyPow6}u) zeIO5tC#r#Mvd^9MhhhsOQ3{}2;j%eg4}JsBgqCEBnj<*bJdGe`q(60R0JN-Wj=xkk zIJI1dqCg_Dg%0RVCnatEt2}53cE(t(o^<k53{k$5ht^|zp*<88)=$02JI4}7Us0xW z@Nu?Pwbi9(#3@AM+`<MoNA_Y~K^L^;aAfE~)ear$Xe#KO*$l0_7*{vysEnpcb+~;M zxIPiAd3%ih>_dnmxNe!dkuU395F+ndc3%|?1Sg5|{tnX*{V9hMj6!xFtusZ>5W4z0 z>;7QH3XgZW>z)C1){ICr++D}~U?@k%3W*lGR<^Y4W6^RI$<B7jA23}yM6haaQU$fA zUc!%Hm+3|jcO|}n?7|qk`-@MfSK%xLMoLP2P(Kz3=d>rr?}T$DZ|Yf7?%T`~*by*i ze|O<)Qb$=;yg)OvP&j#}Utq+`utHq42W=Gpi{VPego0EY8e>%Ivx1xVr7*8+Qu-E9 z;Mz3!?Y|$?Ls}L$hXv&sX*}J!+Vu1Ms?qEujC=imVj2BQ1jKfL?qTbjjC*pv(Q^iE zTsftECEJ<m9%|6@4w~BSXrLUX#pY>d&vih|V7*zYby;p_yax%AhwyDrB-_v6{f=z5 zBAS8rWr#lf_JOA5wz%S#pU@yOo_q-!O^7$!9$3GCbpWz0Szn+eYviDHk__WymV}yl zqB%{39|8{Bq@Vv}jDRx`xHG1>87pa?mfOuxtV3F0B(|d4W9oK*vCe90;v%XIl0Ha2 zaTIH03|lJhQ=E^}?t>v<*+{FX?mr6$?13m0aj%91CBfLG7%tx=nUZ%FPo1sQ)<v%i z->VtCI3()g*aO=>IYAu(RS6N*zr)<Uga^R#vA)`KxQ4KcxYt91@?pD?s|Y$@Y>T^K zG|8!f-=;5cQS~1V#tYO$@&ym*bwP8n;Vib?-X-d8f{k*n((jLsG;kr|O6@|OX1?(Q z7VL*O|48N#?Dkr(F(9^eq{^e_QZxeIFRhdPP-4y*v)#<pI-~-|7<m?{CjfHCK;2#l zcA=|D7PG`cTY3g6(dw%e>tA0=lq9*v+DnZwGqOSl4j4htzjBTCHA)|0d-)0FeX)G` zkNt#X%|w8>ChrDb1ri$0<VMvQ{(LwQ)F6lbY1V~N9%>S2rNJh6M7zWMUu#SOe<6w^ zZQHxV6SSF`);@?p)o-<Il)#so*d&KFy1>HpfISDg#uIEBOWBlKbBC*dd_vHXq5Z0B z@*}`~GrR<Jnw2uJAdVtUzu{&ulU7H0VSu)spk0#1spEERfro*Ir5>jhJB>}Y!<AYi zs?b+(g^wjd*N}8BQIdj2p<ttq@r7v@S`mf^2t<8LoNZ(^5iLOEi_+P>m{Y~tWd*z7 z_eDfYxSHn>Q76x5WG_%O-JShVEhPC203l9H>UM<I-mHC24C)yaenwivvxkKDp|Atj zqGdF<gT5W_I<>M5wF<Ir7^u)4qMD#M=oat3)6;9yoqV;Xf^ske7<p?=<)a?#voPGq zOtx?kiaYz*0%btv5dy)W=7r|<X@PenjI{k}yWE6huRMeXg4XCm$e^#Y3*2`~{&z5k z3cnyg=9?3zga$^U(W4=^9LZd9G9zH<b)j3zaA+8Mk&fHIHQk=rsV2hVjyA&5+eyqa zohqXs-;%xRc_Zmw(u^E@hLSXTGqP6zr6O!|aGxo|wSpB8#!_5IS_LzH0~~Xy!7wL% z33LhN*hPba7(2%2Pk?5$wmXq#p4dSll<8OakyPzlHnBKb<NlA`uR_HBxm0eKC+01( zY|ABy^6yW)=^({iQQQ|jA7_|sL%YpsN%D<E6|IZMBO^gNcFz2P#D1W>{ArHf^|^v6 zA}7g4d{?(||43%|Lf2{Q`@n}MD++WR?%;=xJkjahC5Brk`Lpk$WzXM*G-+G3Dh<{M z1lGM9xjmFB@<ts1sur+@>Q*}1R(?M2;>r!(byN^|%>64Y^4tL2kWh*e>g4{8W$Lkf dpyAng#Ow~#c9$829-*Ngu(GyN{VUt({|6kMvXTG* diff --git a/resources/smeshicondark.png b/resources/smeshicondark.png index 47b4198a8796808610b71a4b5e467943217a3f09..ef4015ac21e82592af386bf8bcddbb7209413029 100644 GIT binary patch delta 7224 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 D47^ge delta 6587 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 diff --git a/resources/smeshiconlight.png b/resources/smeshiconlight.png index 6b96ef2275bb370f05f4b9d3f88d0f1709268a0f..fedaf8034528ae5b4d136f893fa32405d1705fb5 100644 GIT binary patch delta 3290 zcmc&$dpOg58~@JO&}=ozDaS?!O~W=D!#a3!3K1qM+mOm(k&)sz=}Dp#q63oSNGPOI z8WoS6N{p;*OG!?V_n`=H&-MQEK3DJcUf28Ad*AmT_x-s(pX>Aa^DZJKQmT|-(1xB( zaSd3Vc&wgo_g$^tXoDFO^VFV(;L2d5rdCSHS9ooS#qpb=)>$349WxDjT?}@^@RpB- zQQPM<!@o;+b`2VLxrC>;J@0%4pEP-xT6<_bzc|JB!{gEgPoZK=OZ)qnO$9#fFpn`t z`~H_!!hTnGD{^)Xd~6$PHUS%11`$VmBMQ!}RK5{><*JXL_cml<lTs0V1LBxQuv=kr zfx|%AP29=n+uB0BCpC_b>v2zQQcmf3KR=|s-(xL1*C2nyt74^U*XyJW`mp8fJcK@x z>lSJ7ZP2E!)r0QpbxljNd|KXIIk9`^(N-C}>z?*HcNbwDtWdsUb>l@Iy&b|isIOA5 zUZr;lO<#Sp8fQ)C?Rl`y&RJib!?BQPcB$b#JR6dxJuj<p7}aY1Dy6*3Y58%RsfYPx zL2kX}#TB+kGHEXRJ2O9xJ-Eg&tql&;E@wnee;9q!@h-Wq$#!a9cq(G=qYO8rY)?i- z^uuj8g$WpH_syNw<5LBv3&>E0i{mbzP~oG?o|aLcDH5xwu|dbm^*I}g%yMFeUpw2h zc_SS{{;=D=Q+v&sRMS!05cXox@`}SXrPKPP)f4%M(l_s9JNlBG19h8|z+1Y~GuQrf z?_&;0>mRhD#OtyZ17u5;olERdvajvS6O6gneI@hDoE5{#n#*3{%={DEFTJgN+mJ8( z<du(S)v+n*8geC=IhV@u$hiHGWg_Gj9T}$?^KU)Bpmg43Tgwx{l(Kc+b0eO1gjux{ zTm;7vOw1@$7MqA?F)3_3(UeKTQ_TI%@KiH@iU~7->2F42r8^>Za8wf#i$x{+<H-b5 ze>{=G4#YF5L^j@pLLyOE6oLuC)GYl1ay^d4G!GyI{v=X~EDD~;G9lrqffO@5#f%il zG^J7rfn;*}8{`2+bFvA=j7Tvxqo!|@@5Kd>Ni4R%|4#$9`A>sD5*yEAGyU--7L&>* z29k*cGBw>&!BviAW?@FDzNio?FT1?xsg*8+B_c?M9ZI-^=``$mNa2G0j;(v52PWQy zq-c3)wM>7>3{l<dnOt-DvYSuU?tJ=Le*Y%x9U&%%f?2M2&K9dZKmCkvV)6{BYqIH- z-lLa6HEt?r^@5x>)auILiSIw-smSsX7P}S7gs6Hxd^gGMQ~gA|EsDKAH~BbfIQx5C zTKc=fqW0|N2Tv>`0*6tMT^i2*|2G>Usup*vPo#`lygFT#prj^?Zn*)Uj;a+NlVmUw z)Tbj}V%DCsNmK%)_q_s9d<5_JJ9scG6Y>6rOR>&~92zXdTivmOk0WwrVtDJgbN7eA zCz-G$QnWH>_-N!v?uREjDUlTUak*Tr-dW)COsvE^XQ4pBFccrDM(<o31WgdYI9Ni^ zWN3;sG|Hn2y&kUGY_S<;42EkzFE8p>t?tJ{ORJ+T>HtIKb+w&~%$AJ2C&Myryi}Nu zTwoSIvl3unV{FmY>BOaANm{@01wGd_R)WWO5SZqIvxTubY8&tG^O|BVeu`I`*5u+Y z%|=Ol1m0Iqj;O-KDbP-<(jJK`D#3BcHv#t2Pqar6w?!^aE18lOeL|LTS)xl*zrXgI zBZ3sIeILdM20h|4@qPE02Q1^eU?NjB4!icTAj*+DYMN;-V{<M&k1Axsr<~z=4YP07 zOb#8BI0`D9kZ(u0y1ClNvVzNV<bKec!#!`Jqm(Pu)By>qvD{k&WdV_qsifILpW$)J zgsgc#iG$hEdzc_wuIHOe1cpHi@>zq|0<1YMwl?1iL~z0N6&eS;nvG~4m5uxTeuoi` z*7NNb{-NH~L5};DTH8R83XQ{n@XeR?H3u>#ddi((oJEznfI!-1PTqNOXwjd2y<Yyb z#=ewU7pO_A2qEC#JFd8J)KGc>kL>>t!cOkAx8#Mj+_IRRd3Ia%8xO{L|54CKs=}zs zEFX34?d4)a8xCa}ON<w4E6Bh%5I9YQORy3Hr2F;|b1Ad9^qRukgAn;g(VPcd!l<vl z5CYju9YDUy*o%=iXa5$N-jZ|Dwr-0CxH$n;PKQgr3);}x#WlAD<%s4^lq7DUa^r-2 z)!jEblZOgF5heT5f8@wbW8KXUpPAu__+O@{(Go8~;&?-GtY;s2VN_7F!;<}779uBe z61r$DPVTp5D@upH9Kf6$2t88f9xtw2NWL9h0@C-PeaED%wgKH*t7muLI>zFkctHi- zFv8$RK`;73X5GY5Plz_7VDz!y>vo7v<=2gLBr_m)2rnBtyW!5qHE{7&Nmm~z5$(c9 zInoUH{vd^fnKIM3Uh28n(A2-er<~he8Pa(LcedJEjuJ!VXivpRHenP*gOlfDp_<2+ zt2%kHuLliUifi^CJlLcVtuYk_J&{+eMn!jqZrG#B!81EvyvUKUQ(W<IGXWGH9`#r3 zQd9$O^PaCI9a|B+j0W%19u+81n&03D?0O?%(f~e!w`cbaA}Ac4KMm9M=o9g0I_Yrw zi?+KlOW`Gp9@VfWhX>0b_uP{?tZ?mkvQ3F{?#ofba(?}ls#9%P38YeAwGrLCBu0;e zT<Z$-P9LxGr?=$4X!<%adPnrJal4|<TRTM)IziAW?WS(l7!S#oC*~WS5qvzIr13P* z+J|R@9W1NAG=*;btzFg_QK4JT$zZE(4w<!K2uol--|sfp^P=kb^lF8$;mF$eNg|`u z=5ja0`4;VRady#AzHwcHr3eK5cpRKOkkK<UWvf1fx8%B`UT_&|shh(`pL!tZi$}yz zRo?b{w}wi8*IAm%d>^66Z>)*l!^Ne?J$eQBk8d;IlO_k4)RzaOm?eVr!R&=oV;paq zT)Uo~5>I1b&VhJ*<`U(B$Me3l%FVmAtZnCwG@b+jr$MN~Sc;m=GMbfsK@NH~S@>Sx zD$WfBDdNx#(*fCKkNW)N8N0hRj)5`dFXA~hz^?nVXEwAk{}Cd(K?cV0Xss;SayCof zeL`$>IA^gu?cfk~@Des`C{ChzwNI)oS#Kb2ekX`jn43Ncm+UyO{w;Ygw+<T`)$z(& z@?yumKcPUsO4!B3M4qMc8{sMm@~TjJL?M=shS@mR-00$<CCim&H?A{amO~pIoj)y# zus^y#8~T<x-L5o0F3V}RkW4?jcU=D4uE5CqhG&jC%=5$s{e2NA9y`?OSbbO<A8|e2 zhJl3Vy}P$k`{S-x<feHz^}c~LWLcT{)zqNG4SG7X<RQ<yVc4*!Y07W3meeVPEpt%u zJ?&*NV67o7{0LAP%Q~$dIreg?faib|#5A2JZ9H-1ABLU3`CM;g%vbLOpC4Ey#Ez^h zKao|IBT!cFxtT}|)wdi>*@P76N;6-trDaat6X(3TGH|Vb)-wh>wY%xz32HwJ{FMu` z)-)Z+%H2^{pT$`eTc`}-;JTQ4?7PYHg*Ne5!?i5RG(hat*qK5NfH_F6;C`Il+DZc2 zZR6KJ0qJ=Pf_`UK4IJvvkXjdUZJw??<g))lls`2HWQd1Ra~o?<yo&1ncB==)f(zF1 zo*@VCIkjZMT*DXrDc;H$8znFX`GFqJ2sKR_SHzwC?q3X(tfjY3{Bca8<s5t>$_EJ- z0RE(4AuEyqP`qJvhajJYLths@mnXnn{|w8`ps`iefbH?lyK6#I_xS-e&|51uCV+FC zmC>zt*HrZV+<(kgR=~OOFxS(nn@eGNqX<D;W{*2i2b+YveSQ#7X;ct|tJSPPfmDF{ z2_twgU3i`q+S?6I?^OcG{Zy1l4nbdaW^?I>RXkiOz#W&>EycnJEc>ES1P=$Nr@?ev z!9{;yX9{rYAPoine%b$L(!XHn7x90k|HA$stbWn{<z)U#{J+0LZgPtl(U5Ftf0WE? P@U!e~op)4h^E>rVoGYyf delta 2933 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 diff --git a/resources/smeshlight.png b/resources/smeshlight.png index 741927e17c341a2b32709a178808566ccbb56376..ed607936d233d29d3a6c4418395f2206e4cc6c8b 100644 GIT binary patch delta 8059 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 JPDHLkV1i;+H)j9< delta 7758 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 MU0<!@U+C-q0f_drkN^Mx diff --git a/src/App.tsx b/src/App.tsx index f966ab12..c33b8243 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,9 +13,11 @@ import { KindFilterProvider } from '@/providers/KindFilterProvider' import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider' import { MuteListProvider } from '@/providers/MuteListProvider' import { NostrProvider } from '@/providers/NostrProvider' +import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider' import { PinListProvider } from '@/providers/PinListProvider' import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider' import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider' +import { SettingsSyncProvider } from '@/providers/SettingsSyncProvider' import { ThemeProvider } from '@/providers/ThemeProvider' import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider' import { UserTrustProvider } from '@/providers/UserTrustProvider' @@ -29,9 +31,11 @@ export default function App(): JSX.Element { <ThemeProvider> <ContentPolicyProvider> <DeletedEventProvider> - <NostrProvider> - <ZapProvider> - <FavoriteRelaysProvider> + <PasswordPromptProvider> + <NostrProvider> + <SettingsSyncProvider> + <ZapProvider> + <FavoriteRelaysProvider> <FollowListProvider> <MuteListProvider> <UserTrustProvider> @@ -54,9 +58,11 @@ export default function App(): JSX.Element { </UserTrustProvider> </MuteListProvider> </FollowListProvider> - </FavoriteRelaysProvider> - </ZapProvider> - </NostrProvider> + </FavoriteRelaysProvider> + </ZapProvider> + </SettingsSyncProvider> + </NostrProvider> + </PasswordPromptProvider> </DeletedEventProvider> </ContentPolicyProvider> </ThemeProvider> diff --git a/src/PageManager.tsx b/src/PageManager.tsx index a6ba1207..52c13292 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -1,4 +1,5 @@ import Sidebar from '@/components/Sidebar' +import SidebarDrawer from '@/components/SidebarDrawer' import { cn } from '@/lib/utils' import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider' import { TPageRef } from '@/types' @@ -14,7 +15,6 @@ import { useState } from 'react' import BackgroundAudio from './components/BackgroundAudio' -import BottomNavigationBar from './components/BottomNavigationBar' import CreateWalletGuideToast from './components/CreateWalletGuideToast' import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog' import { normalizeUrl } from './lib/url' @@ -49,6 +49,15 @@ const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefi const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined) +type TSidebarDrawerContext = { + isOpen: boolean + open: () => void + close: () => void + toggle: () => void +} + +const SidebarDrawerContext = createContext<TSidebarDrawerContext | undefined>(undefined) + export function usePrimaryPage() { const context = useContext(PrimaryPageContext) if (!context) { @@ -65,6 +74,20 @@ export function useSecondaryPage() { return context } +export function useSidebarDrawer() { + const context = useContext(SidebarDrawerContext) + // Return a no-op fallback when not inside the provider (e.g., desktop sidebar) + if (!context) { + return { + isOpen: false, + open: () => {}, + close: () => {}, + toggle: () => {} + } + } + return context +} + export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home') const [primaryPages, setPrimaryPages] = useState< @@ -76,11 +99,19 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } ]) const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([]) + const [sidebarDrawerOpen, setSidebarDrawerOpen] = useState(false) const { isSmallScreen } = useScreenSize() const { themeSetting } = useTheme() const { enableSingleColumnLayout } = useUserPreferences() const ignorePopStateRef = useRef(false) + const sidebarDrawerContext: TSidebarDrawerContext = { + isOpen: sidebarDrawerOpen, + open: () => setSidebarDrawerOpen(true), + close: () => setSidebarDrawerOpen(false), + toggle: () => setSidebarDrawerOpen((prev) => !prev) + } + useEffect(() => { if (isSmallScreen) return @@ -287,35 +318,40 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { : 0 }} > - <CurrentRelaysProvider> - <NotificationProvider> - {!!secondaryStack.length && - secondaryStack.map((item, index) => ( + <SidebarDrawerContext.Provider value={sidebarDrawerContext}> + <CurrentRelaysProvider> + <NotificationProvider> + {!!secondaryStack.length && + secondaryStack.map((item, index) => ( + <div + key={item.index} + style={{ + display: index === secondaryStack.length - 1 ? 'block' : 'none' + }} + > + {item.element} + </div> + ))} + {primaryPages.map(({ name, element, props }) => ( <div - key={item.index} + key={name} style={{ - display: index === secondaryStack.length - 1 ? 'block' : 'none' + display: + secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none' }} > - {item.element} + {props ? cloneElement(element as React.ReactElement, props) : element} </div> ))} - {primaryPages.map(({ name, element, props }) => ( - <div - key={name} - style={{ - display: - secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none' - }} - > - {props ? cloneElement(element as React.ReactElement, props) : element} - </div> - ))} - <BottomNavigationBar /> - <TooManyRelaysAlertDialog /> - <CreateWalletGuideToast /> - </NotificationProvider> - </CurrentRelaysProvider> + <SidebarDrawer + open={sidebarDrawerOpen} + onOpenChange={setSidebarDrawerOpen} + /> + <TooManyRelaysAlertDialog /> + <CreateWalletGuideToast /> + </NotificationProvider> + </CurrentRelaysProvider> + </SidebarDrawerContext.Provider> </SecondaryPageContext.Provider> </PrimaryPageContext.Provider> ) @@ -341,7 +377,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { > <CurrentRelaysProvider> <NotificationProvider> - <div className="flex lg:justify-around w-full"> + <div className="flex lg:justify-around w-full bg-chrome-background"> <div className="sticky top-0 lg:w-full flex justify-end self-start h-[var(--vh)]"> <Sidebar /> </div> diff --git a/src/application/PublishingService.ts b/src/application/PublishingService.ts new file mode 100644 index 00000000..7412bbcf --- /dev/null +++ b/src/application/PublishingService.ts @@ -0,0 +1,187 @@ +import { Pubkey, Timestamp } from '@/domain/shared' +import { FollowList } from '@/domain/social' +import { MuteList } from '@/domain/social' +import { RelayList } from '@/domain/relay' +import { kinds } from 'nostr-tools' + +/** + * Draft event structure (matches TDraftEvent) + */ +export type DraftEvent = { + kind: number + content: string + created_at: number + tags: string[][] +} + +/** + * Options for publishing a note + */ +export type PublishNoteOptions = { + parentEventId?: string + mentions?: Pubkey[] + hashtags?: string[] + isNsfw?: boolean + addClientTag?: boolean +} + +/** + * PublishingService Domain Service + * + * Handles creation of draft events and publishing logic. + * This service encapsulates the business rules for event creation. + */ +export class PublishingService { + /** + * Create a draft note event + */ + createNoteDraft( + content: string, + options: PublishNoteOptions = {} + ): DraftEvent { + const tags: string[][] = [] + + // Add mention tags + if (options.mentions) { + for (const pubkey of options.mentions) { + tags.push(['p', pubkey.hex]) + } + } + + // Add hashtags + if (options.hashtags) { + for (const hashtag of options.hashtags) { + tags.push(['t', hashtag.toLowerCase()]) + } + } + + // Add NSFW warning + if (options.isNsfw) { + tags.push(['content-warning', 'NSFW']) + } + + // Add client tag + if (options.addClientTag) { + tags.push(['client', 'smesh']) + } + + return { + kind: kinds.ShortTextNote, + content, + created_at: Timestamp.now().unix, + tags + } + } + + /** + * Create a draft reaction event + */ + createReactionDraft( + targetEventId: string, + targetPubkey: string, + targetKind: number, + emoji: string = '+' + ): DraftEvent { + const tags: string[][] = [ + ['e', targetEventId], + ['p', targetPubkey] + ] + + if (targetKind !== kinds.ShortTextNote) { + tags.push(['k', targetKind.toString()]) + } + + return { + kind: kinds.Reaction, + content: emoji, + created_at: Timestamp.now().unix, + tags + } + } + + /** + * Create a draft repost event + */ + createRepostDraft( + targetEventId: string, + targetPubkey: string, + embeddedContent?: string + ): DraftEvent { + return { + kind: kinds.Repost, + content: embeddedContent || '', + created_at: Timestamp.now().unix, + tags: [ + ['e', targetEventId], + ['p', targetPubkey] + ] + } + } + + /** + * Create a draft follow list event from a FollowList aggregate + */ + createFollowListDraft(followList: FollowList): DraftEvent { + return followList.toDraftEvent() + } + + /** + * Create a draft mute list event from a MuteList aggregate + * Note: The caller must handle encryption of private mutes + */ + createMuteListDraft(muteList: MuteList, encryptedPrivateMutes: string = ''): DraftEvent { + return muteList.toDraftEvent(encryptedPrivateMutes) + } + + /** + * Create a draft relay list event from a RelayList aggregate + */ + createRelayListDraft(relayList: RelayList): DraftEvent { + return relayList.toDraftEvent() + } + + /** + * Extract mentions from note content + * Finds npub1, nprofile1, and @mentions + */ + extractMentionsFromContent(content: string): Pubkey[] { + const mentions: Pubkey[] = [] + const seen = new Set<string>() + + // Match npub1 and nprofile1 + const bech32Regex = /n(?:pub1|profile1)[0-9a-z]{58,}/gi + const matches = content.match(bech32Regex) || [] + + for (const match of matches) { + const pubkey = Pubkey.tryFromString(match) + if (pubkey && !seen.has(pubkey.hex)) { + seen.add(pubkey.hex) + mentions.push(pubkey) + } + } + + return mentions + } + + /** + * Extract hashtags from note content + */ + extractHashtagsFromContent(content: string): string[] { + const hashtags: string[] = [] + const matches = content.match(/#[\p{L}\p{N}\p{M}]+/gu) || [] + + for (const match of matches) { + const hashtag = match.slice(1).toLowerCase() + if (hashtag && !hashtags.includes(hashtag)) { + hashtags.push(hashtag) + } + } + + return hashtags + } +} + +/** + * Singleton instance of the publishing service + */ +export const publishingService = new PublishingService() diff --git a/src/application/RelaySelector.ts b/src/application/RelaySelector.ts new file mode 100644 index 00000000..3c09aa60 --- /dev/null +++ b/src/application/RelaySelector.ts @@ -0,0 +1,188 @@ +import { RelayUrl } from '@/domain/shared' +import { RelayList } from '@/domain/relay' + +/** + * Options for relay selection + */ +export type RelaySelectorOptions = { + maxRelays?: number + preferSecure?: boolean + excludeOnion?: boolean + includeDefaultRelays?: boolean +} + +/** + * RelaySelector Domain Service + * + * Handles intelligent selection of relays for various operations. + * Implements relay selection strategies based on context. + */ +export class RelaySelector { + constructor( + private readonly defaultRelays: RelayUrl[] = [] + ) {} + + /** + * Select relays for publishing an event + * Prioritizes write relays from the user's relay list + */ + selectForPublishing( + relayList: RelayList | null, + options: RelaySelectorOptions = {} + ): RelayUrl[] { + const { maxRelays = 5, preferSecure = true, excludeOnion = false } = options + + const candidates: RelayUrl[] = [] + + // Add user's write relays + if (relayList) { + const writeRelays = relayList.getWriteRelays() + candidates.push(...writeRelays) + } + + // Add default relays if needed + if (options.includeDefaultRelays || candidates.length === 0) { + for (const relay of this.defaultRelays) { + if (!candidates.some((c) => c.equals(relay))) { + candidates.push(relay) + } + } + } + + // Filter and sort + let filtered = candidates + if (excludeOnion) { + filtered = filtered.filter((r) => !r.isOnion) + } + + if (preferSecure) { + filtered.sort((a, b) => { + if (a.isSecure && !b.isSecure) return -1 + if (!a.isSecure && b.isSecure) return 1 + return 0 + }) + } + + return filtered.slice(0, maxRelays) + } + + /** + * Select relays for fetching events + * Prioritizes read relays from the user's relay list + */ + selectForFetching( + relayList: RelayList | null, + options: RelaySelectorOptions = {} + ): RelayUrl[] { + const { maxRelays = 5, preferSecure = true, excludeOnion = false } = options + + const candidates: RelayUrl[] = [] + + // Add user's read relays + if (relayList) { + const readRelays = relayList.getReadRelays() + candidates.push(...readRelays) + } + + // Add default relays if needed + if (options.includeDefaultRelays || candidates.length === 0) { + for (const relay of this.defaultRelays) { + if (!candidates.some((c) => c.equals(relay))) { + candidates.push(relay) + } + } + } + + // Filter and sort + let filtered = candidates + if (excludeOnion) { + filtered = filtered.filter((r) => !r.isOnion) + } + + if (preferSecure) { + filtered.sort((a, b) => { + if (a.isSecure && !b.isSecure) return -1 + if (!a.isSecure && b.isSecure) return 1 + return 0 + }) + } + + return filtered.slice(0, maxRelays) + } + + /** + * Select relays for publishing to specific users' inboxes + * Includes mentioned users' read relays + */ + selectForMentions( + authorRelayList: RelayList | null, + mentionedRelayLists: RelayList[], + options: RelaySelectorOptions = {} + ): RelayUrl[] { + const { maxRelays = 8 } = options + + const relaySet = new Map<string, RelayUrl>() + + // Add author's write relays first + if (authorRelayList) { + for (const relay of authorRelayList.getWriteRelays()) { + relaySet.set(relay.value, relay) + } + } + + // Add mentioned users' read relays + for (const relayList of mentionedRelayLists) { + for (const relay of relayList.getReadRelays()) { + relaySet.set(relay.value, relay) + } + } + + // Add defaults if needed + if (options.includeDefaultRelays || relaySet.size === 0) { + for (const relay of this.defaultRelays) { + relaySet.set(relay.value, relay) + } + } + + const candidates = Array.from(relaySet.values()) + + // Filter onion if needed + let filtered = candidates + if (options.excludeOnion) { + filtered = filtered.filter((r) => !r.isOnion) + } + + return filtered.slice(0, maxRelays) + } + + /** + * Get relay URLs as strings (for compatibility with existing code) + */ + selectForPublishingUrls( + relayList: RelayList | null, + options: RelaySelectorOptions = {} + ): string[] { + return this.selectForPublishing(relayList, options).map((r) => r.value) + } + + /** + * Get relay URLs as strings for fetching + */ + selectForFetchingUrls( + relayList: RelayList | null, + options: RelaySelectorOptions = {} + ): string[] { + return this.selectForFetching(relayList, options).map((r) => r.value) + } +} + +/** + * Create a RelaySelector with default relays + */ +export function createRelaySelector(defaultRelayUrls: string[]): RelaySelector { + const defaultRelays = defaultRelayUrls + .map((url) => RelayUrl.tryCreate(url)) + .filter((r): r is RelayUrl => r !== null) + + return new RelaySelector(defaultRelays) +} diff --git a/src/application/index.ts b/src/application/index.ts new file mode 100644 index 00000000..51e0914d --- /dev/null +++ b/src/application/index.ts @@ -0,0 +1,12 @@ +/** + * Application Layer + * + * Use cases and orchestration services. + * Coordinates between domain objects and infrastructure. + */ + +export { RelaySelector, createRelaySelector } from './RelaySelector' +export type { RelaySelectorOptions } from './RelaySelector' + +export { PublishingService, publishingService } from './PublishingService' +export type { DraftEvent, PublishNoteOptions } from './PublishingService' diff --git a/src/assets/GiteaIcon.tsx b/src/assets/GiteaIcon.tsx new file mode 100644 index 00000000..39039e1a --- /dev/null +++ b/src/assets/GiteaIcon.tsx @@ -0,0 +1,13 @@ +export default function GiteaIcon({ className }: { className?: string }) { + return ( + <svg + className={className} + viewBox="0 0 640 640" + fill="currentColor" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M395.9 484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-33.1 68.8c-7.5 15.6 9.4 28.3 23.6 21l68.8-33.1s4.8 9.9 13 27.1c6.1 12.6.7 27.8-11.8 33.8l-43.7 21" /> + <path d="M320 59.5c-144 0-261 116.8-261 261 0 114.4 93 207.1 207.8 207.1h53.2c114.4 0 207.1-92.7 207.1-207.1 0-144.2-117-261-207.1-261zm143.6 293.8c-1.9 52.5-52.6 94.4-94.4 94.4H269c-52.5 0-94.4-42.3-94.4-94.4s42.3-94.4 94.4-94.4c25.6 0 48.8 10.2 65.8 26.7 4.3 4.2 11.3 4.1 15.5-.2 4.2-4.3 4.1-11.3-.2-15.5-21.2-20.6-49.9-33.2-81.1-33.2-64.5 0-116.6 52-116.6 116.6 0 64.5 52 116.6 116.6 116.6h100.3c64.5 0 116.6-52 116.6-116.6 0-3.3-.1-6.6-.4-9.8l-25.5 9.8z" /> + </svg> + ) +} diff --git a/src/assets/smeshdark.png b/src/assets/smeshdark.png index 2625743dac2926d781913acc64d33ae4ed430937..c3651c5a8fa314efdd986cb76b0f6fe5cea18c25 100644 GIT binary patch delta 12898 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 delta 12844 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 diff --git a/src/assets/smeshicondark.png b/src/assets/smeshicondark.png index 47b4198a8796808610b71a4b5e467943217a3f09..ef4015ac21e82592af386bf8bcddbb7209413029 100644 GIT binary patch delta 7224 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 D47^ge delta 6587 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 diff --git a/src/assets/smeshiconlight.png b/src/assets/smeshiconlight.png index 6b96ef2275bb370f05f4b9d3f88d0f1709268a0f..fedaf8034528ae5b4d136f893fa32405d1705fb5 100644 GIT binary patch delta 3290 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 P@U!e~op)4h^E>rVoGYyf delta 2933 zcmbW2do<Kr8^?d+5}|ZCqEi_!qbW?rT+9pxq4eV*Cn-`RW~SqsYc4YyM@ffpnn{=> zUEFeu4sweUry-g{3^So}nOtTFGs>lTwcfMVd)B+&cdfJDXRp1Uy+6-)eV@Jm*lQ=@ z^RbUMK^WfQT89GlE#>rWtRGO!AAWL}@D0{3jvG8P=zf_M>O8LG?-Dt0GekddD}^nL zv(2q+r+1M$z}YKe+}M0;dT!I{+&g1?GgEHgS9<Fk0S)LKIOMdm?j`NmQdz0SVp?{a z#O-ixmi)v1%B-e5T509og4jU3pHhFK){K(=lpDj3TQ_ld(?9lF4kFRDEmfB{A4wD# zBw)-pneOjtbJY*(JG$+u&)xAFqo`C1=@#>Y85`TFp=+V)6$WW0ueZ4_8lJeCBAm4Q z?s`1K`KbKX@6C(rx6x<s2*wO_iVVBbHuXnezV(ak<oze)pLgstm=F}hrv<=|3cO_R zBi1_9ZH)YDSFa6T{q=}aUNYBX8&t>o;oGe+_;ocre$QO$xobBK3`br>mpG{4TRqeQ zligYzKv%1qyRmM^F~$Dw$9VU!j=6B-bDawkFDIjHPgAtIdue@im}F)`+!pX&H##oh z83A#|p;*J&(CCE4>AOd#pC#>{<my2$e0F{Ax~qdxm|uXv1q?fs+-`{2J(t@MIh)U8 zIp65Xb|x<P72X*ymCrXNKe(%>uG(t9Q*y7S@@tPw5zBuQeVv_r!S&Lcnc#$pX*)Q2 z%cva?oVUSe-wemdEdK@TE&0mqR_8=i!kdkSy|4`lld%4Snhj0|bW&uhwcbykd}8Qp z?yc9f<*apx4E^IS>hpCkm|sB`yM`=>$QC=!2!)c`@XzqX6T;fNe;v&uTrItbRaG@B zNC%}&FH*~F|KlIz1@bVQFB)TlB47|mG6sdf;7#!eJQYts5GYhKhJr-l(WX?EqY@NG zHZdXN2zWFCN1;*?7y{A+;X_9HAgCz3FB*xXP<*I(R+<tVW`f3>B7M;&2poZcLtt=J zJi-Tg))#?7k<X$iXOTWw9F;YwL{h<E2n4hV3WGtiY?S+8RJ1RdLd78wCMYZxfieAd z2t1mMz@d;>Qy+?{3C;({GT(Sq5ouzECOrOiW3aM9)rXh~mfSYX1_&IYBiz8U*al}P z8-aEQ-J<!Tp`e!}${wY;^8yjIptz3E<>6qkA!B2{9g}?ckej>op6+3VCS*d3?vCqQ zwo+UME)A-xlJKY(FOhm4zG*MBf}I}+vK@sHz16f^b%ivsxQ#HD(>0_nOPXH3^IGZR zOlmMsw$$9c#OSW;wv_|^6^uXg-%R<@**nzU8ZvVR`~5&?hbkRTB{%`k=6#LY4oa)5 z(VS?e2+Vb^GU1GZrf@=DJ}78Lr*V1m;05?#IQLEi-!j;&s9h0g3bA3<!edS`m<xQf zrX+^Uj+w+<Ei1!<Vuvcqi<SC3(R5;v%g7CKsJiUy5ZDi|t1YVr`>P>FK*8jAkgj@9 znlv+v+s;p2TLXs$&acj1p1A@uWUX3q_JB3VEnS50Iy`5E*$C%MCt=c7m~V%r5|<z# zTp@FB_U@1&!n-X^cuph(tl9kjv37B;W0fxF9INr+y%@AHM~V41Yb;xCr5?@c3L1D_ znVqQtYzNb37lfWS$bKsU<{qHPalR9#sbN9tcA8(aX<ZDFoMe{7(xc%3)gyChav_H` zrYUO2c!ZXLVIF4}$T8!@p*v%-2ag^GyhcV}haP59L<b~e4CTHda2WgI%O^3c#J;qU z#&60}G?l$qY$?!Am1V(5=U=P2q=g_H!#V(A8v(p%{((4@Oa2AW8rGbf@JL&Gj_C|! z?J52QgNhdDS*)=R>4_@CxV1=TqMY|!b;OQy(&m2cUZ)F>F0zHjN2c=IL@G|2wEBGF z+pWNU8i6r40BfH72qQjm-%F@WT6lNwg5Jk47dVsd3EXJd{do$uB={^lp)UP+P0HMQ z5<5vbtq*jZ+Dvz1COLI$_Sps@yWa?Vye?H_8ka&<;=^vrYr?-|f-wWjH1@54f@#&* zC8k6lf?OSM**+t6DRzeu{J$Srfv?U(D>uWwFtXNf687$-d8Jc(abNYZ)o$a^S61Wv z+hcMNWX^cYEIYopTpjv;#S+0ewu}+kmflxv@y?XD`sqVgQEvD93a#CaRSxM?w~-yd zrBj=|2As*;&r>BOt#lr#@0Q2Vk%*rGtyf|1P}_t1CEd19h2gzrowOC#C2EWMuREu? z42klI-x|tOm~@L*{Vtmc`=7wNShTFV1%9|ut=bfHw;kV$JyGe_a=pwk>|%pGH%gnL zKoKk20oFK#2EaXi9-Q9*0fwsAK~x-y<F&qy@MmjaAi$o;Y#?4dwKB^8eesFVw6Lxr zz?OZ&lgJvg@)NIF9^e?Vaxxebl>_J6R6Pr)e!&>etO^ejiN94;1gykLuP#bvFzIB$ zKA=P5%@i<K4{(TnXMQj{uFAjBY{5xFH7NyOtFQJe*J;w@++kUmr^=Qd)KoyhaM!NJ z<dqPRxy%2nu4CQKBA$BaXAkx+?^uc}`;<)s-L@+YX60~x<ZA<x!@wPzO*tK0MC91- z2c(5D<HxK>^C48WeOQ!V>xkgWNNC*5bO39$^7(M7CO~-;2M(L%<#suRMcSKw%@BpI z=)3~wQ#+OyybKtg+<giyPCL8H%1JvvrOJM{HB*nIP~yx;fLcX4CnVFc8GUb;Ov5%e z6D!(CcL3B1%MBTMjBu6_v@%uJskj*F%oG?-@@f@Y-US>#*cnz>=&_)km?~o@OiUDu zI&3G24aWhsnp}slu(NTiACv=Er+*w~uL=!63W##vk0k3a7H`$n7lJjGG*z}d)y^3E z<2kLim;@be0*s}ZFe~E_FIunHrK2U#2QiAL{Py-VGIR>aa9(3se~r>yS^_PGKGk?H zh{oKmDxNm+6C3ZVONt}Fs+Hz!N)}`zx^BsPGIF=nn{lF_jS4a#mdUg$&Rc=Cr6!g3 zXkQ9(D5JR9&5cA_Mp^@lf3U#da_^kZ-o}{P9i4pJ_@1)B)`w%Q-$A(cxcoYCeZ=d6 zIwGTH2hSbV-Q-ukwy%#4;mSx4JxhzoIqDDUDoCVrvp+$&R_cWW$jKCJ#<hi;9TU&K zhj5*0kDCrx%jJc~MJjXc8AXAO>E~k8wRc@f^*a-FnpFnY49I`H8Rypx@Tj)$M^AZ7 z()U$Y_{!y3i8~gx@gB+cD$tRK4#HNw(+u{iTwa~D`S31P=*aD51HeBdDu48A)H7c} z_C)C9I!Laq?+HC`7#*nYW&y3)7%$dx`2z3t&na3`1t3Q+G2ASqM8$Mb#ky3%nvp@b z<;KQo09d5!itqiWDgcB_+P+>k`NpC#i+m|29cd4IHwzxi6XtT|80!|C8?;gTuJ&qc z;Ei_xFKtNykb4(%D{f8B8i?RsAT>W#i5K(VPtT?ddFlZEk9v6{nk&Cq1IqE@FrVXq zG60DO9+mJ9t*je-&Yvm=5nF(Z#p`uo3$Pht4dC4sA?q7!0FYZJ@c^Iz=m5Z<i1A<8 wH}r4C{}B9#&)-%3wfDC__{a76|3<+~bNaGQ<CMO(@;3|ckh5L6jd#L70j^9GJpcdz diff --git a/src/assets/smeshlight.png b/src/assets/smeshlight.png index 741927e17c341a2b32709a178808566ccbb56376..ed607936d233d29d3a6c4418395f2206e4cc6c8b 100644 GIT binary patch delta 8059 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 JPDHLkV1i;+H)j9< delta 7758 zcmYjWWmp?L*9N)*gW?bF3>i)-&VcPfhO^;LX>kU0!?o=;7#;2o6J)r{7}5;}3?1%J ze7Hl0zC73W=Sy<sN3P`LBsu54lao2yx7P8zge(bsYsh~;E}Kb|{3d(!hii`5pl`v2 z)0g--^u+?~MC-D*nn#9t!#M2eZidS0K<!1mtYCZLfGP#Xuee8eB0cYK(W{Z!-=V6x z*MIqvW^E5k*)Ssesx%8L9G@@E-*pMP^+u{MylakYGX1US^v|Rb^E`~Q$FduC_Ij_n zD<XTV`r`<avWy%&MWsDfJo3SNAcn<SH>Aei$rJ5(pFN&T7O#`&RP;UK?VshI#D-T7 z@2j-^o@prCWeMEj_IoWv7d^0gvBJ?-BG*;M_sK+o(H{S^R$)n<7nesj_V{fVWl06W z;x#<W8hFn)eEKDpB@U4xm%Nau!{CDc1ZykFE%O2(5u{ea+1!Na^uw@xP+atMB()~V z2eL^ET28LVC38!>0tq0wFO)km&e5qt!gjD06Fy+FD+7QvSp-^;-@~aB%T^+kX)Ik@ z>^_{yH`LoVeH5Z(MyFGlI?(@yzEmvNTIt)@;6uwMQrkt#XGK4C1nXg=WPS7oW)H`L z4!EBp6;68Iv4<ri#v)E!i(UH$G8;$SHpJM03*L=;ttUYv7-NOeVrS*uliF-wuJMRy zDW6q)d;G)C%40GUoEKitKI_FW78kovNox*x(n;9~C}g0S1c(p-X=+uNfzz-cU+yYD zBqZ(`eJ{dLAX`6)iL@}uoJ46y<}3E}c8TFhKcnnT<?0W$bM;p$uP&MEmD_1OXukC# zh^5I&sLl;4cYMQaRdPBi|7laWl|GsLT|@@JyhpMvDXs*$m;Vb&ld4q%%mR>d@?Z&w zl$4{WgFGA}DkUKS7nPHek`k4=Yj>2DvX^kQcj6)A;^GSMK<McyXnA`3*n7eqwbYb& z?pnlLT^$r0q+oD}w7rw4BpePGm4ewzh|0sjQlc_oc_|48SxG5-Il2EQ!r+ywv#Y25 z3pFpeuSZfY2^FtBTuNF_Qc_A3F6Uq`D&^=XD{3zZmKTM<?rKLEL<S}!lQc|XM+O1Q zgJtEV!4T;r1=4X|xFgI_>TXg|ISKfk#tu$WqH;2D8ByuGCBY=&4sf`GbQ0vgJ_$qt zBCj9~u8q9!NqUcha+fjbf(AlDBmkLSpK(c&q7^{0#%rlPF%Fp7oeylWF$p|4wCj1S z@ozdEKQm7fw<hU(uAUE((C2~b@#Yr{9}UZEC))JuCO$43ziWK>=EF1kIuw~X(7{dZ z84;642GKn_UTQ9CTF+OP-L<b}xjU53b89CRf^XaMhAylz8w;)HGaZ3vEV;l$UakKt zNn5neiF9;G-xO|Bv`|x*AAuhcKR{ehMV7FOXk}#RITnVSHqO;j;Uw9!E0_a38ZI~m zvrH8jDCrV6kjVToVuYjZi<NV?jl_>pUqgF$u>;t=)_$R{v37}rld+qbx%XaVR;<O0 zO1Fdwsg4G2Tjc`R0aWFNdzu?YyGnL+qk%JoQ<TUlo}3~*L*~4}@krwdk{~o6cEmW9 zzyJ_eP{VZ{LfRy*6vv^NmjVKfKhGmj9{t!J3Oe{qX=@}7um!Ni_wut(4d!6$vA&xd z*WtO{FR^W*TQG5GKvv@+sRVfwZIcL)g(aH;4M3X_j-r%|xP}Ff(TnGTW&m;}it7ep zq=ZsVX)CIDldp`d5B(0Ul7D%yZoH?1c9Q){Dp&K#Lyypj(VDRqJuh^XYrg7Hc5^SH zm0if$AByxRX-i+o4~8TMqmkT}%dc1gE$sL}H?1$tV(SEU26nM-qD^?I1J(}$qrNSl z9#LJf;HigaDpEWW_o}^7Dvru#?m}@$LKVUr?p6_hRn>lV`iY@FvdXAw`iY<BUao7D zppE)XZfQciOOTP+COSmv;2hiYerTE-OBLyPV7Rs1h`nFeo>C*mPsAyHz+ziJM;XF3 z@2w}T0DO~Hi2d5w*ZX<1;7SuVg+_n;FTcMJ3dU4aqh~;Pmf;hdqc?baB#sx>Vp{~# zvNLUDuw^_a+$Wego*;s%;NE3e`j8mq0^7)U=0AAX0}k|HWp-YAy*#*XY;`ygLmWXI z;f8?X4+(chBkSzu&rogTawUL`KTw#+bJ_8qoRQV$dcIuXuu97BKMTf!o3MRoa+ISq z8L63B?!7jOmLJ&P*k{OIW+vOo<s02fyvC-QeAF38GcW$ev~i;2xHF)i-&fp#uWM&q z^ou!hNM5?H6(il~waGaO;kzb-yH-mj0;mF1i$}0k@6rROIrl$bHqT8MJJD^zh}{$t zaz9b}zElE>d>G`Xo{v`=Q~VG>i6@;`GKP|Zr=4g9$Vts6-y`w#JnD)6ZH}=Pbc(#i zm;F*AI{nnF@!cK|&KDpj0EujI@-2x97KHQr3$(=cP)+5dmK9*SwvE%YO+QeGdhr8B zYIz0<5Cuqh>G6q^;tSE+MNM20)^75kshfb2sM2%EU-@209Fwz!z$^fQ@lo}yRzBFr zibwK}>D3^`5#(UHRx36&-35i7r$<pBV1u@Jej-c7dxnmu=Akp0OKOyD{63RA<8j`H zZNy_gJ2uI`%z8Xo<*uv0-!=ynzxCp8{;p)_E@sk}JqvS*`pCk;bVd|h<}|w@_2=U^ zRDIlve60EOhx@)Sh5r(_Hg@E$>QvdfeT{VC79Unbez~0*@_Uq5(%<a{b%DB=)!2^Z zToG<fhqmyu(gZG<PQFs0kbAQ(!Y&WQo2KnumZD4DMz*H)pAt>xZ@2K05IED?6j;`J z8%C8lK);BfmwaFr-wFdM4i-#qJlGOYehwX5FyezQvDuAD+Lim=fr^zAxt%OC1$Ejl z()L7=W-AtrURFR!3FGlI!mc8MKfykJDe;M+xhc*YVJTZi>d7aLW1>2hyH9l!S$4}? zOoIW8M)Nxp(#%BJI%U3P_1_XT2gRA0N(?(t;aC`SgLtyz%L%nDczxw9@#If69s%1; zKfNbQnqDhm@}zQkE%S{6M3qOzCw;D_Xb=d=m$Iyc?E>yEuB)B@xo2Rab3=wVF7)sM zg?u;qEq;u_aNSu)K2J12+4@f#1u%@@#fmFSyaDx!Tj`l6;BT1X8}nvENr`0R=p@0~ ztTPA}otyha>3j$vQ;x`bfNF2tx^$FBG>>xuRJ9724e}KQf1$D6kEGwo6ffgeT#%nG z?Y8}xCA!_>@1~(;xBJ}|U1$-=r?{(0+~9<N-12j4PgWr3VX3_-I=?=EeRu@Yjtc$D zR^8w&m`eTYb0vu<k7eLG(GYd*)l=Hqd0C<czN2$J65A*8?nX#pI+@5ePDX>^U4Zau z9~;iF^a*i9qPBFhCReEE`gJ<l6;hSk_Z9mAr&>w2y8GX`+F}ucoAetG_48dB?DNj1 zZ|o3`K2PkoKRfSbFQ0219r9r*1k>9e0PU#?yqnU^?Hzcl-{R8$`Q6T&vTG=z5;G_U z4p0DhJxjFBFR4#dv`H-@+4pYRa}}?a*%|GO?(m%6s3mLS6chizQNd07qY*h8)QSH_ z0^yj<+kEl*fXZ3f>%1`Sy<ZtXS*kTZ8+0WCyxE-@D!?*i>_aBCZ=$*?QbYh9eksl< z9aJ3c*Af0Al9!7w%Q{A~&Oc(EF?)^8#3=8`dZ+=f;Tv6^X8KDO)iQ&S7jA;-W(U=) zK5FYLNHV$Nj&8XK^OyXSV5Hv>ey}&dy2w}V54*Yucc5RhK5=a&<39(j@H>Ba2<T|2 z`_z(wE|Lxm4C0#LW(pz0-xjOeb438W_?o|JD>h`qFFgkX=KPIz-#->8GCeDa^Y$Z& z+q}@-*z12^Ir@B)Z$EJi@_Xxs!{I-HzTVIP$U(0R$3D6fd+mwb<+x$k)mcy!$+^va z9Sfc;8<BN*v~JNUS6k95JbLKo_PVEoId<(=zg>e>VKBZeY;WHt89cl2A9xZ+09YV+ zR|UvR*V+@uIS-qzVBbG10qnc%9a8Cu?#%V>5cd6?RU6uaQs&sd%arVWNfa)k)=Aug zxHbg4co3(E0}}Zkayde$3@wOST06Rx2%B1UUjNAO2|JB^BMAT0`<HnS-73kx%>$k6 zKfgubx<Kdt0!~!oeF#b&-Pdi>39mMXP0oB@7N)x1fpN#U<oEiEQpR+`E9ZBO$M+8S zVe6j1Jc%hEIQjUE&?i?ry^MW_SA?){caQXG=?~E%>C%FP=GI9+ZMWrUI9}vhSmGcn z+_+ns;`jPIU9?HI%^c^#8Y9ig6R-g7pWU~%y&i(u2KBCd5%HBezUqMu%f~8Y=IKjv zUP3$Jf9u}Iwa<%(Z96vGwoe0x@Z5sb;>BzI3?1!N!WP64?t-#T_!>5{Ia3#CfLg@1 zeH;-oHaH}_JJ`Aq9s;)?LVHAtEtJF}xrbxs_2s*MEkr57{^L17SXR=6lYF&;>i9H1 zjWAEVN;!<+>QRTE*I?azTgeMPC83VPdDF-2m0RCW9Bon#>r^o3pFpP#N(VBWi+2Dr zvrqKtr^GwXk-y-O@_<~IUju8b8F5IMI&zj-tG}mWCS!BARm$18rL}eT>iks<<Bh`b zvd}N=Gmn#k=-IuG$4NGpFBuZjMAG`uPbEE(weK6eNpo!C{Crax{mM^I-Fen@ObQ$S z?hgzYdu8P}&wlT2x`X4hCI-CCdT3MAhl`$hSYx(%o^;wUy~jossN3%WxcG~j5)v!^ zO-`Z(0-yIV(0!=T)&2Eu*!N3Y!pj4dRp!riC8Zd!%hzlFE|0h}7sX3wxduCiFmYSE zrG|zFma{W!75%=AgkTxGA@nKd_T55>o$ep35S_sV5vqijFKxLu)@CwEBIr=_lC7xZ zvaOeGMl<-vJ&}A$ss~3#VOg3;8Btg12;aXql_WbEy~Z$+U%XK7PqXg43Bp@njdOXM zqt<D3GUoMpnR9@TR@SN#`!Hkld`*}Z@_zBpGZ`P<kC_r4%62o(UeLdQs~nXv2OBM& zuPD)bdCkh)lxJ*muvk7;q7+EfxuqC>aHNilQ{F4sqXfMJyra{pWlyw3GEF$@3bE3` z^-aeCbN|+LS!?*Pin-$JpBqljh-NgZB-J=QU9?=mB7!1TGR>y`+KeiT75`y-9PE6o z{G{$;-{@IrESKrTU#mOHPeX5m?&t4qX>%Be2uP)w$6u9SgUBvfxeNdc2GL8F1XuUu zQU}Y}-JnyMmk_Vpk{^S}foth7+nojMcBx8GvEOu^Us2#+_3w*lFYOp2S!&Gqy9dln z3?7<6wci-=B2})ySvEjRxTYPL6R#ZjO|n!b?^PEt<=|&%J=)%}&|zvlTdX)s$Y`QY zQKF@ae9K<}A(mi#Wn`T7+s07EgVOqmyw1mYeP3b8RcqRP=WrV&Nr&XbPsx&@;zlvZ zmyMulI41c~YwAt`9ps>Lr$)g%lZE8W<G25wy6vUKd!6OeQ7IU6RifpV-^b>0&R5OV zRo9z@xxIC}mk+&QBd;@U{gZw{4%NgbFliqv{Vdo)0ugjJX>C#ZLc8SFZidh%yP$6( z<$}ojYZbA-^=rwHb4AvW{9CAvv6GeYdwpKWt-RwZ5qo5@T4vT)tSs85Rm}lvo@0Zl z=%*l?Ym6%PBM1=lr*AUQfq0zzZU4Ylp*4JQUS~x6pZZU~T)G2r^Mcq`GC3B3(jIKp z=Pue}fQ(38+n^g(dRTSg6s1ERv2M=zpHF*uvm=7^kXwHUa8Eax4-M+Xbs~~YTmwPv z{Wyk4c?S7xP2n!nR_R1J&($V-7_g?UtVUKLcqPzP3E^btb@8C~aY*5=vdf+3iE*ni z5Z|k7-(|j4PV^g+zh&aT)NtNv3iS|7b+UKMTL-9Iz&C&ZCGOm`pY4+JAd4X`lq^By zQWJ`-^bNUTgLOH~EA4nZ4}qGSv04GunHN4{5!?v1AX_i<&lfD@EpdO`lo|0L$Fnv~ zcN0UiBbCvM=uXziwSyb4s3+WNrBfm}JIs}d5vr2T;=*iWC&HR8F2Jg66<qp3lGOsJ zzPSoIRK%#01-Y4fbwu)=@9|htHy#@%qLKF7{DfXF4*kRa?YH%u6lt8^^zfoZJQ5z# zXpPvK6q1$@>G-!4i+#raGLVciuXOW};z8=%H*$RDWFrV<<qawqtqKdg$kC6k4~jLn z4s|0lbtYQ$_$>`yoLvS+ec2CaqxEp1ml{;ZZvRRvsw3;bXuJzXQPIL>`2Zcha>)Gf zA!XHQ^&KBZHSq+0cRN^!z0sphkHgnR<*&74YRFhN-JnR=^r%ACW8JD2jBlK48Ve`W z-slhKAppmhfZHHeAH7Fwcl`@YpA__yOj<V*1XH(4`!=#7l)y>T6RONi6<zCf6X7Qh z46@Mn>N^@0XBKt2<G)>^A`j=gk%qTgcB+`UcMz~%5(s4Hkj0>a%ql8hp>k$sG95^z zD?d<$3gD!+@_1gbRLflF=K3)+-TZ7~Q2xdiFg7~zT*>si_GDJ(v528O2Z=uMS>dU% z!@LtMLKN|($VNH3<ACAZKfh=I`>Y<mU1qv*jx9ws68Mkd<(>j=Q({e!ZXr;-T2$WR z!st3<om=4isU?Nw8*+>~ss4wyQkockNqgNxpVCeA(u!!<!ur{ur4#)_I%l-I%}`-2 z>X%w?jY45#M*QoJ!(2~kd5P>vn9lcKb*UgLLw|D^{g*s(YG-OXW2a~B_c627bYA&g z#1T>Efq%dHPSfB~MfxR3_N3BM`o74P1|w7lhQ4}hnF+_93zL?Xc-s@Q_<o46^6*5f zK2g|z{vkASO})U%(Cm~bji^%Z$#TXR{CZ8CW`(jqgE%6s4*z$lQ!cvWQMjRpNKc*3 zq)SEIG*%H^jz-?w+g^rGg8~|hjP)o$NUiN+(k=oOfO4$-?|h^}&p1r(dr9S5{fRp4 zQ|}Vi(PyLVJT8B~St0Y^Y0>o<8{N;zsFE~u>lPDLG!hsV<N6}sQ&x7=&nB^~EtPJ* z-%In!a{H)FUzn$Mv}`g9BNI`!UKy)A^(7shxiScJT&Ha#Xx6ysP})Yb2V9_fp{@&U zY1cWs9LQCWmPSfgYxMsJ3PQE3u*lIj0p&W4gnic-{V5eKPh*ax4(d<-MS!ZCwSQ55 ztS^8U%-o9*wgfpzG&xTD0mIaF^{0OsiQHLOM_>7ch9&V8=@fZ@Qnl?%%!I+EfV8B< zJ-i@-?TsCJ1%Wfg*^R{T6~Ca07x;C$kS^7}d6UJIi1d*_grYAS8;dmcC_#jSBnyK$ z@8>ZD%39GXb-@-wRt7;Cr8DNxl5ugIHk1=rj?I33<}PaxlgYtUK}u<-Dv}{<o`l(3 zt3^{h+BK|x6?1+eTxuJ7#WWS(6Etjuy<uYN#Y+4Y9e#V0C3D@nf?>d1E1eo9gr&KD zdunltwEPMC)a}ZN+v~rqq0oPvfL<c!AhM+hY1Z@doTUw<YS;9U))DIN*PRVzuKG4! zDcw@mlK97I(AC=@90a<BF@rIyCY^~-cjflt@>%Dz^n|+ES9N@@ZA<Ok5AGrEAym6U ztA5^HRZCw!NU&UfQzsC7-L~nK*{v(oy_V5{3}&lVF0WIFLc^J_@-+c>?%H5MbzlN@ z##s}$->gkw$FY>Ql!i26DX!lhppDAH%72D-@@o9ok>QU)r?%iFb&@eLoHl_vxFzjv z^88)=NOx8lqnRjP{wpw}L9w?a4Q+pWB?EjXKtN7>=<i27c#;@jV?}D6>N<O8Q2Wc7 z+m^M9V0>-~+Wf_MAGYc?2wiK4IPZ9JmGc?(xm6^8CoKNuY%n12=pPq~TtIGr)x5fC z>mB%=GlAvs9%`i>k3%Tax)WbrPIK)o`QhabkZ!i!4>0t~3cGn<CVM4SQNzR$k`g;& z8-QTRs<nDQ7cApz<7Veyigj?L67Eusa=dx^Z4db+7dWAEG?v7ZpFv1Y8JIu+f;R9K zPI(D__h@_Lty0AaUTHG%Iv$Cyx#UmikV;GFH1Ae*H_QtrC@Mf5`~w|*-*~GH)7Z~L z?GuntmwU~x_kd<Ya%(0$Q_=R5K`a4u2!p70|A&48I$xCttdj#dyQq=#GXg%Y?6ClS zURuZy(M{}Ct(C0qrH=`j-%U)9!7N1tp@8$$UTB70;vEkQlVkRxOrweUg9A0EbBIKS zTBthEkjVSs`S=mHdk8rM<pJrif^6&W(0La8Lxd3-Z95@b_Ew8p9$%_K+048$nFDmn z7UHVpTJg0%|H_FDgWS<UfDnuagG70EyDdvy?6*!AXmQKJG^@T5#EMhu?3IlpVx$($ zE-2GIwTB6gvxV({eoP#(>u-Mtnmv80LO%r1eBnEN>)1^!XXM`R;fymlDR}h!!Dhkv zeI77=k=mwy-cuDui*v<tV(fv=`OxzM`u#o)we9WVRaPWj;MX`plL9R*>q@V~W7dE` zP4$3y%?GDLz}?XbHOAd`!dswqHc_2^*=I%2RBHO88rfmdN9FcIUpz1`bA`Kt#JCt_ zn{t^H1Fgt7W-|hU$RysFQwTU$wMH^M5W2JG=_>}6_PIMnyBH&15Ef7zrvDazS`f_< z&1u#>R7R%v!qqNver|rPQ3g{Y)Gij`k^e|$QA5II`3yhpr1$C|_)<+gbbWH#KMu7k zk<TUczQJ~2=T43>${V<8AC3qb(Y6)z0ID37gPO-gGgvXcDl?<4O}t}Q8F%UZF^DaP zAvl%Rrg746k2?Jn!_eBQWMdJ{Z)sRGzSA^_JR2*~)?<M;nMCE;{aNFtcjO-~5Rv}* zh{xvcz^4ey*MBun-YGvQh?O>d!?OJCaok-fqhm0A_=j^aCIgY!TLTJEERSPOZ5}Yf znw1eVGx>Gh)XD5^$iT+`(OSzOZ%Ig3;~F1ljKt^|?h<vF0V;<AqT!M2cvI`m0&XPz zt_Du7pW{PN5Gg)${6jNw#4IiOupJ>=I`_k?`&FS)?dNcDisAr98e#Z}C4EW4ni|jk zPt))%7N!aW^)RhJpxW=ShtkW7HUV+=n*q7+$-@_sf0Xk?<he8AOEdlvB4zvo{~c`W z3LHre0B-<OX=h>(sciGnBYkKdA@b^`*p|W2Og_q-Y>*eb<!43%*)PxlMhL&S*FxST z2E;fMJSDWE#CcKZJ;BLFikue_tq>ph?|nEuj7&Vb<N&8mUwz&(Z6}HCJdx@xOHMK1 zuQ^gkKaS)p;NVJ?v8>J>riIPnnv5H`84?#`zWeK)6Cic+JL1PeD|E#pkt#rbEA}lO z|9r-*qB62ODp_DCHr_Pqe7%#{Jeh;d?UYDiHhtHO^+L(oyRp#2lu8Lk(t0+^iHjK+ z2=}?1{ATLQ5_6t9YzK^$#G~!y+bCP^Vo7crm)Jop(vn`NABF>{m7dX-suD-6&7}(v z?sxEhHMAKJpta{D>aRg6m>x;#H}ox8Lj852<-{Zr$EbpE_H$h*70ySTu$ZQTppft> z-3oQqjy>)())XrsJhfnB8zw$>xkyMtaBj}-_6iyxUj-O;%nI%rSHgm^VQM_?S_8x6 z){h;)a_^n^YV0)19{WmF(~cF>E62s%E2I{6(94E`<NjpK&>-=Y{O~kWNd`HZrp!NW zyX7B549bCC+(+zwQ3l4wDy6Au^p>2m&R?RY!zX?YW>mx&x#4v)Q`^cIok~W9Dwg7C zgrB59?|}j5b1aj8-yf-{EuTKO@lml}^&cR2GBam{Q&4rJb~3e-qk$e3s{&{{>Ll%k zoskQ$gw$O|mJRldW%|(^=rWQ#P{K-M-x-pd{JIO6(WwTlkMT0Kkl<;nbkY_`{(CZ5 z95W5-d2KCf-Ci5=+)n9?0z8DFM$i>w*9Vbnd#QA_O{oNNbZ`1@lRbMBQC3DT|Hb6r z1X0SVq$G6C>^;rTQ>1TM>>JS;l@9G)u`%4xLF*t5Ee_caEO|ovguks8+*t73?!I3^ zJSu3m)w*A?-}Mri*h@+)t`~mty4jA*w2&5}E#Nd&8IJYnEO{o}E5hRiXD`E3;!Q91 zF<9Zqy&D@tRNI3$c57}Qa<wU_Yf-F*88JFj#ZD3Cz{Ihd7^4qIiN*~;8J*iU8VNL$ zm5d%wioKe6*Rf!HfO1NKV3n1o$n{r=l<r82GOANS1IyjAw5_1+f8_MFRb)wI?%n<S zbe$GowiU>Z2QMC3m)v$6{`D53B04Wb9SoVaCmqs46Y1EP=$Da%FLG7joL%zV1b0bG MU0<!@U+C-q0f_drkN^Mx diff --git a/src/components/AccountManager/BunkerLogin.tsx b/src/components/AccountManager/BunkerLogin.tsx deleted file mode 100644 index 0cb3c924..00000000 --- a/src/components/AccountManager/BunkerLogin.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { useNostr } from '@/providers/NostrProvider' -import { Loader } from 'lucide-react' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' - -export default function BunkerLogin({ - back, - onLoginSuccess -}: { - back: () => void - onLoginSuccess: () => void -}) { - const { t } = useTranslation() - const { bunkerLogin } = useNostr() - const [pending, setPending] = useState(false) - const [bunkerInput, setBunkerInput] = useState('') - const [errMsg, setErrMsg] = useState<string | null>(null) - - const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { - setBunkerInput(e.target.value) - setErrMsg(null) - } - - const handleLogin = () => { - if (bunkerInput === '') return - - setPending(true) - bunkerLogin(bunkerInput) - .then(() => onLoginSuccess()) - .catch((err) => setErrMsg(err.message)) - .finally(() => setPending(false)) - } - - return ( - <> - <div className="space-y-1"> - <Input - placeholder="bunker://..." - value={bunkerInput} - onChange={handleInputChange} - className={errMsg ? 'border-destructive' : ''} - /> - {errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>} - </div> - <Button onClick={handleLogin} disabled={pending}> - <Loader className={pending ? 'animate-spin' : 'hidden'} /> - {t('Login')} - </Button> - <Button variant="secondary" onClick={back}> - {t('Back')} - </Button> - </> - ) -} diff --git a/src/components/AccountManager/NostrConnectionLogin.tsx b/src/components/AccountManager/NostrConnectionLogin.tsx deleted file mode 100644 index 37fde9d0..00000000 --- a/src/components/AccountManager/NostrConnectionLogin.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants' -import { cn } from '@/lib/utils' -import { useNostr } from '@/providers/NostrProvider' -import { Check, Copy, Loader, ScanQrCode } from 'lucide-react' -import { generateSecretKey, getPublicKey } from 'nostr-tools' -import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46' -import QrScanner from 'qr-scanner' -import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import QrCode from '../QrCode' - -export default function NostrConnectLogin({ - back, - onLoginSuccess -}: { - back: () => void - onLoginSuccess: () => void -}) { - const { t } = useTranslation() - const { nostrConnectionLogin, bunkerLogin } = useNostr() - const [pending, setPending] = useState(false) - const [bunkerInput, setBunkerInput] = useState('') - const [copied, setCopied] = useState(false) - const [errMsg, setErrMsg] = useState<string | null>(null) - const [nostrConnectionErrMsg, setNostrConnectionErrMsg] = useState<string | null>(null) - const qrContainerRef = useRef<HTMLDivElement>(null) - const [qrCodeSize, setQrCodeSize] = useState(100) - const [isScanning, setIsScanning] = useState(false) - const videoRef = useRef<HTMLVideoElement>(null) - const qrScannerRef = useRef<QrScanner | null>(null) - const qrScannerCheckTimerRef = useRef<NodeJS.Timeout | null>(null) - - const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { - setBunkerInput(e.target.value) - if (errMsg) setErrMsg(null) - } - - const handleLogin = (bunker: string = bunkerInput) => { - const _bunker = bunker.trim() - if (_bunker.trim() === '') return - - setPending(true) - bunkerLogin(_bunker) - .then(() => onLoginSuccess()) - .catch((err) => setErrMsg(err.message || 'Login failed')) - .finally(() => setPending(false)) - } - - const [loginDetails] = useState(() => { - const newPrivKey = generateSecretKey() - const newMeta: NostrConnectParams = { - clientPubkey: getPublicKey(newPrivKey), - relays: DEFAULT_NOSTRCONNECT_RELAY, - secret: Math.random().toString(36).substring(7), - name: document.location.host, - url: document.location.origin - } - const newConnectionString = createNostrConnectURI(newMeta) - return { - privKey: newPrivKey, - connectionString: newConnectionString - } - }) - - useLayoutEffect(() => { - const calculateQrSize = () => { - if (qrContainerRef.current) { - const containerWidth = qrContainerRef.current.offsetWidth - const desiredSizeBasedOnWidth = Math.min(containerWidth - 8, containerWidth * 0.9) - const newSize = Math.max(100, Math.min(desiredSizeBasedOnWidth, 360)) - setQrCodeSize(newSize) - } - } - - calculateQrSize() - - const resizeObserver = new ResizeObserver(calculateQrSize) - if (qrContainerRef.current) { - resizeObserver.observe(qrContainerRef.current) - } - - return () => { - if (qrContainerRef.current) { - resizeObserver.unobserve(qrContainerRef.current) - } - resizeObserver.disconnect() - } - }, []) - - useEffect(() => { - if (!loginDetails.privKey || !loginDetails.connectionString) return - setNostrConnectionErrMsg(null) - nostrConnectionLogin(loginDetails.privKey, loginDetails.connectionString) - .then(() => onLoginSuccess()) - .catch((err) => { - console.error('NostrConnectionLogin Error:', err) - setNostrConnectionErrMsg( - err.message ? `${err.message}. Please reload.` : 'Connection failed. Please reload.' - ) - }) - }, [loginDetails, nostrConnectionLogin, onLoginSuccess]) - - const copyConnectionString = async () => { - if (!loginDetails.connectionString) return - - navigator.clipboard.writeText(loginDetails.connectionString) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - const startQrScan = async () => { - try { - setIsScanning(true) - setErrMsg(null) - - // Wait for next render cycle to ensure video element is in DOM - await new Promise((resolve) => setTimeout(resolve, 100)) - - if (!videoRef.current) { - throw new Error('Video element not found') - } - - const hasCamera = await QrScanner.hasCamera() - if (!hasCamera) { - throw new Error('No camera found') - } - - const qrScanner = new QrScanner( - videoRef.current, - (result) => { - setBunkerInput(result.data) - stopQrScan() - handleLogin(result.data) - }, - { - highlightScanRegion: true, - highlightCodeOutline: true, - preferredCamera: 'environment' - } - ) - - qrScannerRef.current = qrScanner - await qrScanner.start() - - // Check video feed after a delay - qrScannerCheckTimerRef.current = setTimeout(() => { - if ( - videoRef.current && - (videoRef.current.videoWidth === 0 || videoRef.current.videoHeight === 0) - ) { - setErrMsg('Camera feed not available') - } - }, 1000) - } catch (error) { - setErrMsg( - `Failed to start camera: ${error instanceof Error ? error.message : 'Unknown error'}. Please check permissions.` - ) - setIsScanning(false) - if (qrScannerCheckTimerRef.current) { - clearTimeout(qrScannerCheckTimerRef.current) - qrScannerCheckTimerRef.current = null - } - } - } - - const stopQrScan = () => { - if (qrScannerRef.current) { - qrScannerRef.current.stop() - qrScannerRef.current.destroy() - qrScannerRef.current = null - } - setIsScanning(false) - if (qrScannerCheckTimerRef.current) { - clearTimeout(qrScannerCheckTimerRef.current) - qrScannerCheckTimerRef.current = null - } - } - - useEffect(() => { - return () => { - stopQrScan() - } - }, []) - - return ( - <div className="relative flex flex-col gap-4"> - <div ref={qrContainerRef} className="flex flex-col items-center w-full space-y-3 mb-3"> - <a href={loginDetails.connectionString} aria-label="Open with Nostr signer app"> - <QrCode size={qrCodeSize} value={loginDetails.connectionString} /> - </a> - {nostrConnectionErrMsg && ( - <div className="text-xs text-destructive text-center pt-1">{nostrConnectionErrMsg}</div> - )} - </div> - <div className="flex justify-center w-full mb-3"> - <div - className="flex items-center gap-2 text-sm text-muted-foreground bg-muted px-3 py-2 rounded-full cursor-pointer transition-all hover:bg-muted/80" - style={{ - width: qrCodeSize > 0 ? `${Math.max(150, Math.min(qrCodeSize, 320))}px` : 'auto' - }} - onClick={copyConnectionString} - role="button" - tabIndex={0} - > - <div className="flex-grow min-w-0 truncate select-none"> - {loginDetails.connectionString} - </div> - <div className="flex-shrink-0">{copied ? <Check size={14} /> : <Copy size={14} />}</div> - </div> - </div> - - <div className="flex items-center w-full my-4"> - <div className="flex-grow border-t border-border/40"></div> - <span className="px-3 text-xs text-muted-foreground">OR</span> - <div className="flex-grow border-t border-border/40"></div> - </div> - - <div className="w-full space-y-1"> - <div className="flex items-start space-x-2"> - <div className="flex-1 relative"> - <Input - placeholder="bunker://..." - value={bunkerInput} - onChange={handleInputChange} - className={errMsg ? 'border-destructive pr-10' : 'pr-10'} - /> - <Button - size="sm" - variant="ghost" - className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0" - onClick={startQrScan} - disabled={pending} - > - <ScanQrCode /> - </Button> - </div> - <Button onClick={() => handleLogin()} disabled={pending}> - <Loader className={pending ? 'animate-spin mr-2' : 'hidden'} /> - {t('Login')} - </Button> - </div> - - {errMsg && <div className="text-xs text-destructive pl-3 pt-1">{errMsg}</div>} - </div> - <Button variant="secondary" onClick={back} className="w-full"> - {t('Back')} - </Button> - - <div className={cn('w-full h-full flex justify-center', isScanning ? '' : 'hidden')}> - <video - ref={videoRef} - className="absolute inset-0 w-full h-full bg-background" - autoPlay - playsInline - muted - /> - <Button - variant="secondary" - size="sm" - className="absolute top-2 right-2" - onClick={stopQrScan} - > - Cancel - </Button> - </div> - </div> - ) -} diff --git a/src/components/AccountManager/PrivateKeyLogin.tsx b/src/components/AccountManager/PrivateKeyLogin.tsx index 8ca195d1..c8b54978 100644 --- a/src/components/AccountManager/PrivateKeyLogin.tsx +++ b/src/components/AccountManager/PrivateKeyLogin.tsx @@ -3,8 +3,76 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useNostr } from '@/providers/NostrProvider' -import { useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { ScanLine, X } from 'lucide-react' +import QrScanner from 'qr-scanner' + +function QrScannerModal({ + onScan, + onClose +}: { + onScan: (result: string) => void + onClose: () => void +}) { + const { t } = useTranslation() + const videoRef = useRef<HTMLVideoElement>(null) + const scannerRef = useRef<QrScanner | null>(null) + const [error, setError] = useState<string | null>(null) + + const handleScan = useCallback( + (result: QrScanner.ScanResult) => { + onScan(result.data) + onClose() + }, + [onScan, onClose] + ) + + useEffect(() => { + if (!videoRef.current) return + + const scanner = new QrScanner(videoRef.current, handleScan, { + preferredCamera: 'environment', + highlightScanRegion: true, + highlightCodeOutline: true + }) + + scannerRef.current = scanner + + scanner.start().catch(() => { + setError(t('Failed to access camera')) + }) + + return () => { + scanner.destroy() + } + }, [handleScan, t]) + + return ( + <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center"> + <div className="relative w-full max-w-sm mx-4"> + <Button + variant="ghost" + size="icon" + className="absolute -top-12 right-0 text-white hover:bg-white/20" + onClick={onClose} + > + <X className="h-6 w-6" /> + </Button> + <div className="rounded-lg overflow-hidden bg-black"> + {error ? ( + <div className="p-8 text-center text-destructive">{error}</div> + ) : ( + <video ref={videoRef} className="w-full" /> + )} + </div> + <p className="text-center text-white/70 text-sm mt-4"> + {t('Point camera at QR code')} + </p> + </div> + </div> + ) +} export default function PrivateKeyLogin({ back, @@ -35,12 +103,18 @@ function NsecLogin({ back, onLoginSuccess }: { back: () => void; onLoginSuccess: const [nsecOrHex, setNsecOrHex] = useState('') const [errMsg, setErrMsg] = useState<string | null>(null) const [password, setPassword] = useState('') + const [showScanner, setShowScanner] = useState(false) const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { setNsecOrHex(e.target.value) setErrMsg(null) } + const handleScan = (result: string) => { + setNsecOrHex(result) + setErrMsg(null) + } + const handleLogin = () => { if (nsecOrHex === '') return @@ -52,49 +126,65 @@ function NsecLogin({ back, onLoginSuccess }: { back: () => void; onLoginSuccess: } return ( - <form - className="space-y-4" - onSubmit={(e) => { - e.preventDefault() - handleLogin() - }} - > - <div className="text-orange-400"> - {t( - 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.' - )} - </div> - <div className="grid gap-2"> - <Label htmlFor="nsec-input">nsec or hex</Label> - <Input - id="nsec-input" - type="password" - placeholder="nsec1.. or hex" - value={nsecOrHex} - onChange={handleInputChange} - className={errMsg ? 'border-destructive' : ''} - /> - {errMsg && <div className="text-xs text-destructive">{errMsg}</div>} - </div> - <div className="grid gap-2"> - <Label htmlFor="password-input">{t('password')}</Label> - <Input - id="password-input" - type="password" - placeholder={t('optional: encrypt nsec')} - value={password} - onChange={(e) => setPassword(e.target.value)} - /> - </div> - <div className="flex gap-2"> - <Button className="w-fit px-8" variant="secondary" type="button" onClick={back}> - {t('Back')} - </Button> - <Button className="flex-1" type="submit"> - {t('Login')} - </Button> - </div> - </form> + <> + {showScanner && ( + <QrScannerModal onScan={handleScan} onClose={() => setShowScanner(false)} /> + )} + <form + className="space-y-4" + onSubmit={(e) => { + e.preventDefault() + handleLogin() + }} + > + <div className="text-orange-400"> + {t( + 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.' + )} + </div> + <div className="grid gap-2"> + <Label htmlFor="nsec-input">nsec or hex</Label> + <div className="flex gap-2"> + <Input + id="nsec-input" + type="password" + placeholder="nsec1.. or hex" + value={nsecOrHex} + onChange={handleInputChange} + className={errMsg ? 'border-destructive' : ''} + /> + <Button + type="button" + variant="outline" + size="icon" + onClick={() => setShowScanner(true)} + title={t('Scan QR code')} + > + <ScanLine className="h-4 w-4" /> + </Button> + </div> + {errMsg && <div className="text-xs text-destructive">{errMsg}</div>} + </div> + <div className="grid gap-2"> + <Label htmlFor="password-input">{t('password')}</Label> + <Input + id="password-input" + type="password" + placeholder={t('optional: encrypt nsec')} + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + </div> + <div className="flex gap-2"> + <Button className="w-fit px-8" variant="secondary" type="button" onClick={back}> + {t('Back')} + </Button> + <Button className="flex-1" type="submit"> + {t('Login')} + </Button> + </div> + </form> + </> ) } @@ -109,12 +199,18 @@ function NcryptsecLogin({ const { ncryptsecLogin } = useNostr() const [ncryptsec, setNcryptsec] = useState('') const [errMsg, setErrMsg] = useState<string | null>(null) + const [showScanner, setShowScanner] = useState(false) const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { setNcryptsec(e.target.value) setErrMsg(null) } + const handleScan = (result: string) => { + setNcryptsec(result) + setErrMsg(null) + } + const handleLogin = () => { if (ncryptsec === '') return @@ -126,33 +222,49 @@ function NcryptsecLogin({ } return ( - <form - className="space-y-4" - onSubmit={(e) => { - e.preventDefault() - handleLogin() - }} - > - <div className="grid gap-2"> - <Label htmlFor="ncryptsec-input">ncryptsec</Label> - <Input - id="ncryptsec-input" - type="password" - placeholder="ncryptsec1.." - value={ncryptsec} - onChange={handleInputChange} - className={errMsg ? 'border-destructive' : ''} - /> - {errMsg && <div className="text-xs text-destructive">{errMsg}</div>} - </div> - <div className="flex gap-2"> - <Button className="w-fit px-8" variant="secondary" type="button" onClick={back}> - {t('Back')} - </Button> - <Button className="flex-1" type="submit"> - {t('Login')} - </Button> - </div> - </form> + <> + {showScanner && ( + <QrScannerModal onScan={handleScan} onClose={() => setShowScanner(false)} /> + )} + <form + className="space-y-4" + onSubmit={(e) => { + e.preventDefault() + handleLogin() + }} + > + <div className="grid gap-2"> + <Label htmlFor="ncryptsec-input">ncryptsec</Label> + <div className="flex gap-2"> + <Input + id="ncryptsec-input" + type="password" + placeholder="ncryptsec1.." + value={ncryptsec} + onChange={handleInputChange} + className={errMsg ? 'border-destructive' : ''} + /> + <Button + type="button" + variant="outline" + size="icon" + onClick={() => setShowScanner(true)} + title={t('Scan QR code')} + > + <ScanLine className="h-4 w-4" /> + </Button> + </div> + {errMsg && <div className="text-xs text-destructive">{errMsg}</div>} + </div> + <div className="flex gap-2"> + <Button className="w-fit px-8" variant="secondary" type="button" onClick={back}> + {t('Back')} + </Button> + <Button className="flex-1" type="submit"> + {t('Login')} + </Button> + </div> + </form> + </> ) } diff --git a/src/components/AccountManager/index.tsx b/src/components/AccountManager/index.tsx index 9ac595e5..61d46f5d 100644 --- a/src/components/AccountManager/index.tsx +++ b/src/components/AccountManager/index.tsx @@ -5,12 +5,11 @@ import { useNostr } from '@/providers/NostrProvider' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AccountList from '../AccountList' -import NostrConnectLogin from './NostrConnectionLogin' import NpubLogin from './NpubLogin' import PrivateKeyLogin from './PrivateKeyLogin' import Signup from './Signup' -type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | 'signup' | null +type TAccountManagerPage = 'nsec' | 'npub' | 'signup' | null export default function AccountManager({ close }: { close?: () => void }) { const [page, setPage] = useState<TAccountManagerPage>(null) @@ -19,8 +18,6 @@ export default function AccountManager({ close }: { close?: () => void }) { <> {page === 'nsec' ? ( <PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} /> - ) : page === 'bunker' ? ( - <NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} /> ) : page === 'npub' ? ( <NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} /> ) : page === 'signup' ? ( @@ -54,9 +51,6 @@ function AccountManagerNav({ {t('Login with Browser Extension')} </Button> )} - <Button variant="secondary" onClick={() => setPage('bunker')} className="w-full"> - {t('Login with Bunker')} - </Button> <Button variant="secondary" onClick={() => setPage('nsec')} className="w-full"> {t('Login with Private Key')} </Button> diff --git a/src/components/BottomNavigationBar/AccountButton.tsx b/src/components/BottomNavigationBar/AccountButton.tsx index 7b3e8b4f..6c8b397c 100644 --- a/src/components/BottomNavigationBar/AccountButton.tsx +++ b/src/components/BottomNavigationBar/AccountButton.tsx @@ -1,19 +1,18 @@ import { Skeleton } from '@/components/ui/skeleton' import { LONG_PRESS_THRESHOLD } from '@/constants' -import { cn } from '@/lib/utils' -import { usePrimaryPage } from '@/PageManager' +import { toProfile } from '@/lib/link' +import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { UserRound } from 'lucide-react' -import { useMemo, useRef, useState } from 'react' +import { useRef, useState } from 'react' import LoginDialog from '../LoginDialog' import { SimpleUserAvatar } from '../UserAvatar' import BottomNavigationBarItem from './BottomNavigationBarItem' export default function AccountButton() { - const { navigate, current, display } = usePrimaryPage() - const { pubkey, profile } = useNostr() + const { push } = useSecondaryPage() + const { pubkey, profile, checkLogin } = useNostr() const [loginDialogOpen, setLoginDialogOpen] = useState(false) - const active = useMemo(() => current === 'me' && display, [display, current]) const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const handlePointerDown = () => { @@ -26,8 +25,12 @@ export default function AccountButton() { const handlePointerUp = () => { if (pressTimerRef.current) { clearTimeout(pressTimerRef.current) - navigate('me') pressTimerRef.current = null + if (pubkey) { + push(toProfile(pubkey)) + } else { + checkLogin() + } } } @@ -36,16 +39,13 @@ export default function AccountButton() { <BottomNavigationBarItem onPointerDown={handlePointerDown} onPointerUp={handlePointerUp} - active={active} + active={false} > {pubkey ? ( profile ? ( - <SimpleUserAvatar - userId={pubkey} - className={cn('size-6', active ? 'ring-primary ring-2' : '')} - /> + <SimpleUserAvatar userId={pubkey} className="size-6" /> ) : ( - <Skeleton className={cn('size-6 rounded-full', active ? 'ring-primary ring-2' : '')} /> + <Skeleton className="size-6 rounded-full" /> ) ) : ( <UserRound /> diff --git a/src/components/BottomNavigationBar/BookmarkButton.tsx b/src/components/BottomNavigationBar/BookmarkButton.tsx new file mode 100644 index 00000000..3085bc0d --- /dev/null +++ b/src/components/BottomNavigationBar/BookmarkButton.tsx @@ -0,0 +1,20 @@ +import { usePrimaryPage } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' +import { Bookmark } from 'lucide-react' +import BottomNavigationBarItem from './BottomNavigationBarItem' + +export default function BookmarkButton() { + const { navigate, current, display } = usePrimaryPage() + const { pubkey } = useNostr() + + if (!pubkey) return null + + return ( + <BottomNavigationBarItem + active={current === 'bookmark' && display} + onClick={() => navigate('bookmark')} + > + <Bookmark /> + </BottomNavigationBarItem> + ) +} diff --git a/src/components/BottomNavigationBar/PostButton.tsx b/src/components/BottomNavigationBar/PostButton.tsx new file mode 100644 index 00000000..a3004405 --- /dev/null +++ b/src/components/BottomNavigationBar/PostButton.tsx @@ -0,0 +1,21 @@ +import PostEditor from '@/components/PostEditor' +import { useNostr } from '@/providers/NostrProvider' +import { SquarePen } from 'lucide-react' +import { useState } from 'react' +import BottomNavigationBarItem from './BottomNavigationBarItem' + +export default function PostButton() { + const { checkLogin } = useNostr() + const [open, setOpen] = useState(false) + + return ( + <> + <BottomNavigationBarItem + onClick={() => checkLogin(() => setOpen(true))} + > + <SquarePen /> + </BottomNavigationBarItem> + <PostEditor open={open} setOpen={setOpen} /> + </> + ) +} diff --git a/src/components/BottomNavigationBar/ExploreButton.tsx b/src/components/BottomNavigationBar/SearchButton.tsx similarity index 57% rename from src/components/BottomNavigationBar/ExploreButton.tsx rename to src/components/BottomNavigationBar/SearchButton.tsx index 9f8cd6c5..6baa8e68 100644 --- a/src/components/BottomNavigationBar/ExploreButton.tsx +++ b/src/components/BottomNavigationBar/SearchButton.tsx @@ -1,16 +1,16 @@ import { usePrimaryPage } from '@/PageManager' -import { Compass } from 'lucide-react' +import { Search } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' -export default function ExploreButton() { +export default function SearchButton() { const { navigate, current, display } = usePrimaryPage() return ( <BottomNavigationBarItem - active={current === 'explore' && display} - onClick={() => navigate('explore')} + active={current === 'search' && display} + onClick={() => navigate('search')} > - <Compass /> + <Search /> </BottomNavigationBarItem> ) } diff --git a/src/components/BottomNavigationBar/SettingsButton.tsx b/src/components/BottomNavigationBar/SettingsButton.tsx new file mode 100644 index 00000000..a6adbaa5 --- /dev/null +++ b/src/components/BottomNavigationBar/SettingsButton.tsx @@ -0,0 +1,20 @@ +import { toSettings } from '@/lib/link' +import { usePrimaryPage, useSecondaryPage } from '@/PageManager' +import { useUserPreferences } from '@/providers/UserPreferencesProvider' +import { Settings } from 'lucide-react' +import BottomNavigationBarItem from './BottomNavigationBarItem' + +export default function SettingsButton() { + const { current, navigate, display } = usePrimaryPage() + const { push } = useSecondaryPage() + const { enableSingleColumnLayout } = useUserPreferences() + + return ( + <BottomNavigationBarItem + active={current === 'settings' && display} + onClick={() => (enableSingleColumnLayout ? navigate('settings') : push(toSettings()))} + > + <Settings /> + </BottomNavigationBarItem> + ) +} diff --git a/src/components/BottomNavigationBar/index.tsx b/src/components/BottomNavigationBar/index.tsx index 32c91367..c07cbb6a 100644 --- a/src/components/BottomNavigationBar/index.tsx +++ b/src/components/BottomNavigationBar/index.tsx @@ -1,14 +1,20 @@ import { cn } from '@/lib/utils' +import { useScreenSize } from '@/providers/ScreenSizeProvider' import BackgroundAudio from '../BackgroundAudio' import AccountButton from './AccountButton' -import ExploreButton from './ExploreButton' +import BookmarkButton from './BookmarkButton' import HomeButton from './HomeButton' import NotificationsButton from './NotificationsButton' +import PostButton from './PostButton' +import SearchButton from './SearchButton' +import SettingsButton from './SettingsButton' export default function BottomNavigationBar() { + const { isTabletScreen } = useScreenSize() + return ( <div - className={cn('fixed bottom-0 w-full z-40 bg-background border-t')} + className={cn('fixed bottom-0 w-full z-40 bg-chrome-background border-t')} style={{ paddingBottom: 'env(safe-area-inset-bottom)' }} @@ -16,8 +22,15 @@ export default function BottomNavigationBar() { <BackgroundAudio className="rounded-none border-x-0 border-t-0 border-b bg-background" /> <div className="w-full flex justify-around items-center [&_svg]:size-4 [&_svg]:shrink-0"> <HomeButton /> - <ExploreButton /> <NotificationsButton /> + {isTabletScreen && ( + <> + <SearchButton /> + <BookmarkButton /> + <PostButton /> + <SettingsButton /> + </> + )} <AccountButton /> </div> </div> diff --git a/src/components/Explore/index.tsx b/src/components/Explore/index.tsx deleted file mode 100644 index aa5f0cd2..00000000 --- a/src/components/Explore/index.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton' -import { useFetchRelayInfo } from '@/hooks' -import { toRelay } from '@/lib/link' -import { recommendRelaysByLanguage } from '@/lib/relay' -import { cn } from '@/lib/utils' -import { useSecondaryPage } from '@/PageManager' -import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider' -import relayInfoService from '@/services/relay-info.service' -import { TAwesomeRelayCollection } from '@/types' -import { useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo' - -export default function Explore() { - const { t, i18n } = useTranslation() - const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null) - const recommendedRelays = useMemo(() => { - const lang = i18n.language - const relays = recommendRelaysByLanguage(lang) - return relays - }, [i18n.language]) - - useEffect(() => { - relayInfoService.getAwesomeRelayCollections().then(setCollections) - }, []) - - if (!collections && recommendedRelays.length === 0) { - return ( - <div> - <div className="p-4 max-md:border-b"> - <Skeleton className="h-6 w-20" /> - </div> - <div className="grid md:px-4 md:grid-cols-2 md:gap-2"> - <RelaySimpleInfoSkeleton className="h-auto px-4 py-3 md:rounded-lg md:border" /> - </div> - </div> - ) - } - - return ( - <div className="space-y-6"> - {recommendedRelays.length > 0 && ( - <RelayCollection - collection={{ - id: 'recommended', - name: t('Recommended'), - relays: recommendedRelays - }} - /> - )} - {collections && - collections.map((collection) => ( - <RelayCollection key={collection.id} collection={collection} /> - ))} - </div> - ) -} - -function RelayCollection({ collection }: { collection: TAwesomeRelayCollection }) { - const { deepBrowsing } = useDeepBrowsing() - return ( - <div> - <div - className={cn( - 'sticky bg-background z-20 px-4 py-3 text-2xl font-semibold max-md:border-b', - deepBrowsing ? 'top-12' : 'top-24' - )} - > - {collection.name} - </div> - <div className="grid md:px-4 md:grid-cols-2 md:gap-3"> - {collection.relays.map((url) => ( - <RelayItem key={url} url={url} /> - ))} - </div> - </div> - ) -} - -function RelayItem({ url }: { url: string }) { - const { push } = useSecondaryPage() - const { relayInfo, isFetching } = useFetchRelayInfo(url) - - if (isFetching) { - return <RelaySimpleInfoSkeleton className="h-auto px-4 py-3 border-b md:rounded-lg md:border" /> - } - - if (!relayInfo) { - return null - } - - return ( - <RelaySimpleInfo - key={relayInfo.url} - className="clickable h-auto px-4 py-3 border-b md:rounded-lg md:border" - relayInfo={relayInfo} - onClick={(e) => { - e.stopPropagation() - push(toRelay(relayInfo.url)) - }} - /> - ) -} diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index c25cc4f0..6a63f18f 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -4,7 +4,7 @@ import UserAggregationList, { TUserAggregationListRef } from '@/components/UserA import { isTouchDevice } from '@/lib/utils' import { useKindFilter } from '@/providers/KindFilterProvider' import { useUserTrust } from '@/providers/UserTrustProvider' -import storage from '@/services/local-storage.service' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { TFeedSubRequest, TNoteListMode } from '@/types' import { useMemo, useRef, useState } from 'react' import KindFilter from '../KindFilter' @@ -28,7 +28,11 @@ export default function NormalFeed({ const { hideUntrustedNotes } = useUserTrust() const { showKinds } = useKindFilter() const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) - const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode()) + const [listMode, setListMode] = useState<TNoteListMode>(() => { + const stored = storage.getNoteListMode() + // If stored mode was 'posts', use 'postsAndReplies' instead since 'posts' tab is removed + return stored === 'posts' ? 'postsAndReplies' : stored + }) const supportTouch = useMemo(() => isTouchDevice(), []) const noteListRef = useRef<TNoteListRef>(null) const userAggregationListRef = useRef<TUserAggregationListRef>(null) @@ -41,6 +45,7 @@ export default function NormalFeed({ setListMode(mode) if (isMainFeed) { storage.setNoteListMode(mode) + dispatchSettingsChanged() } topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) } @@ -53,9 +58,8 @@ export default function NormalFeed({ return ( <> <Tabs - value={listMode === '24h' && disable24hMode ? 'posts' : listMode} + value={listMode === '24h' && disable24hMode ? 'postsAndReplies' : listMode} tabs={[ - { value: 'posts', label: 'Notes' }, { value: 'postsAndReplies', label: 'Replies' }, ...(!disable24hMode ? [{ value: '24h', label: '24h Pulse' }] : []) ]} diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 4bf8598d..45f96cb4 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -9,37 +9,37 @@ import { createShortTextNoteDraftEvent, deleteDraftEventCache } from '@/lib/draft-event' -import { isTouchDevice } from '@/lib/utils' +import { cn, isTouchDevice } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' +import { useScreenSize } from '@/providers/ScreenSizeProvider' import postEditorCache from '@/services/post-editor-cache.service' import threadService from '@/services/thread.service' import { TPollCreateData } from '@/types' import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react' import { Event, kinds } from 'nostr-tools' -import { useEffect, useMemo, useRef, useState } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import EmojiPickerDialog from '../EmojiPickerDialog' import Mentions from './Mentions' import PollEditor from './PollEditor' import PostOptions from './PostOptions' -import PostRelaySelector from './PostRelaySelector' import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import Uploader from './Uploader' -export default function PostContent({ - defaultContent = '', - parentStuff, - close, - openFrom, - highlightedText -}: { - defaultContent?: string - parentStuff?: Event | string - close: () => void - openFrom?: string[] - highlightedText?: string -}) { +export type TPostContentHandle = { + reset: () => void +} + +const PostContent = forwardRef< + TPostContentHandle, + { + defaultContent?: string + parentStuff?: Event | string + close: () => void + highlightedText?: string + } +>(({ defaultContent = '', parentStuff, close, highlightedText }, ref) => { const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() const [text, setText] = useState('') @@ -52,13 +52,12 @@ export default function PostContent({ () => (parentStuff && typeof parentStuff !== 'string' ? parentStuff : undefined), [parentStuff] ) + const { isSmallScreen } = useScreenSize() const [showMoreOptions, setShowMoreOptions] = useState(false) const [addClientTag, setAddClientTag] = useState(false) const [mentions, setMentions] = useState<string[]>([]) const [isNsfw, setIsNsfw] = useState(false) const [isPoll, setIsPoll] = useState(false) - const [isProtectedEvent, setIsProtectedEvent] = useState(false) - const [additionalRelayUrls, setAdditionalRelayUrls] = useState<string[]>([]) const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({ isMultipleChoice: false, options: ['', ''], @@ -73,20 +72,27 @@ export default function PostContent({ (!!text || !!highlightedText) && !posting && !uploadProgresses.length && - (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && - (!isProtectedEvent || additionalRelayUrls.length > 0) + (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) ) - }, [ - pubkey, - text, - highlightedText, - posting, - uploadProgresses, - isPoll, - pollCreateData, - isProtectedEvent, - additionalRelayUrls - ]) + }, [pubkey, text, highlightedText, posting, uploadProgresses, isPoll, pollCreateData]) + + useImperativeHandle(ref, () => ({ + reset: () => { + textareaRef.current?.clear() + setText('') + setMentions([]) + setIsNsfw(false) + setIsPoll(false) + setPollCreateData({ + isMultipleChoice: false, + options: ['', ''], + endsAt: undefined, + relays: [] + }) + setAddClientTag(false) + setMinPow(0) + } + })) useEffect(() => { if (isFirstRender.current) { @@ -140,18 +146,15 @@ export default function PostContent({ pollCreateData, pubkey, addClientTag, - isProtectedEvent, + isProtectedEvent: false, isNsfw }) - const _additionalRelayUrls = [...additionalRelayUrls] - if (parentStuff && typeof parentStuff === 'string') { - _additionalRelayUrls.push(...BIG_RELAY_URLS) - } + const additionalRelayUrls = + parentStuff && typeof parentStuff === 'string' ? [...BIG_RELAY_URLS] : [] const newEvent = await publish(draftEvent, { - specifiedRelayUrls: isProtectedEvent ? additionalRelayUrls : undefined, - additionalRelayUrls: isPoll ? pollCreateData.relays : _additionalRelayUrls, + additionalRelayUrls: isPoll ? pollCreateData.relays : additionalRelayUrls, minPow }) postEditorCache.clearPostCache({ defaultContent, parentStuff }) @@ -197,9 +200,9 @@ export default function PostContent({ } return ( - <div className="space-y-2"> + <div className={cn('space-y-2', isSmallScreen && 'flex flex-col h-full')}> {parentEvent && ( - <ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40"> + <ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40 shrink-0"> <div className="p-2 sm:p-3 pointer-events-none"> {highlightedText ? ( <div className="flex gap-4"> @@ -219,22 +222,25 @@ export default function PostContent({ defaultContent={defaultContent} parentStuff={parentStuff} onSubmit={() => post()} - className={isPoll ? 'min-h-20' : 'min-h-52'} + className={cn(isPoll ? 'min-h-20' : 'min-h-52', isSmallScreen && 'flex-1')} + fillHeight={isSmallScreen} onUploadStart={handleUploadStart} onUploadProgress={handleUploadProgress} onUploadEnd={handleUploadEnd} placeholder={highlightedText ? t('Write your thoughts about this highlight...') : undefined} /> {isPoll && ( - <PollEditor - pollCreateData={pollCreateData} - setPollCreateData={setPollCreateData} - setIsPoll={setIsPoll} - /> + <div className="shrink-0"> + <PollEditor + pollCreateData={pollCreateData} + setPollCreateData={setPollCreateData} + setIsPoll={setIsPoll} + /> + </div> )} {uploadProgresses.length > 0 && uploadProgresses.map(({ file, progress, cancel }, index) => ( - <div key={`${file.name}-${index}`} className="mt-2 flex items-end gap-2"> + <div key={`${file.name}-${index}`} className="mt-2 flex items-end gap-2 shrink-0"> <div className="min-w-0 flex-1"> <div className="truncate text-xs text-muted-foreground mb-1"> {file.name ?? t('Uploading...')} @@ -259,15 +265,7 @@ export default function PostContent({ </button> </div> ))} - {!isPoll && ( - <PostRelaySelector - setIsProtectedEvent={setIsProtectedEvent} - setAdditionalRelayUrls={setAdditionalRelayUrls} - parentEvent={parentEvent} - openFrom={openFrom} - /> - )} - <div className="flex items-center justify-between"> + <div className="flex items-center justify-between shrink-0"> <div className="flex gap-2 items-center"> <Uploader onUploadSuccess={({ url }) => { @@ -341,27 +339,19 @@ export default function PostContent({ </div> </div> </div> - <PostOptions - posting={posting} - show={showMoreOptions} - addClientTag={addClientTag} - setAddClientTag={setAddClientTag} - isNsfw={isNsfw} - setIsNsfw={setIsNsfw} - minPow={minPow} - setMinPow={setMinPow} - /> - <div className="flex gap-2 items-center justify-around sm:hidden"> - <Button - className="w-full" - variant="secondary" - onClick={(e) => { - e.stopPropagation() - close() - }} - > - {t('Cancel')} - </Button> + <div className="shrink-0"> + <PostOptions + posting={posting} + show={showMoreOptions} + addClientTag={addClientTag} + setAddClientTag={setAddClientTag} + isNsfw={isNsfw} + setIsNsfw={setIsNsfw} + minPow={minPow} + setMinPow={setMinPow} + /> + </div> + <div className="sm:hidden shrink-0"> <Button className="w-full" type="submit" disabled={!canPost} onClick={post}> {posting && <LoaderCircle className="animate-spin" />} {parentStuff ? t('Reply') : t('Post')} @@ -369,7 +359,10 @@ export default function PostContent({ </div> </div> ) -} +}) + +PostContent.displayName = 'PostContent' +export default PostContent async function createDraftEvent({ parentStuff, diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx deleted file mode 100644 index 820abde9..00000000 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Label } from '@/components/ui/label' -import { Separator } from '@/components/ui/separator' -import { isProtectedEvent } from '@/lib/event' -import { simplifyUrl } from '@/lib/url' -import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useScreenSize } from '@/providers/ScreenSizeProvider' -import client from '@/services/client.service' -import { Check } from 'lucide-react' -import { NostrEvent } from 'nostr-tools' -import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import RelayIcon from '../RelayIcon' - -type TPostTargetItem = - | { - type: 'writeRelays' - } - | { - type: 'relay' - url: string - } - | { - type: 'relaySet' - id: string - urls: string[] - } - -export default function PostRelaySelector({ - parentEvent, - openFrom, - setIsProtectedEvent, - setAdditionalRelayUrls -}: { - parentEvent?: NostrEvent - openFrom?: string[] - setIsProtectedEvent: Dispatch<SetStateAction<boolean>> - setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>> -}) { - const { t } = useTranslation() - const { isSmallScreen } = useScreenSize() - const [isDrawerOpen, setIsDrawerOpen] = useState(false) - const { relayUrls } = useCurrentRelays() - const { relaySets, favoriteRelays } = useFavoriteRelays() - const [postTargetItems, setPostTargetItems] = useState<TPostTargetItem[]>([]) - const parentEventSeenOnRelays = useMemo(() => { - if (!parentEvent || !isProtectedEvent(parentEvent)) { - return [] - } - return client.getSeenEventRelayUrls(parentEvent.id) - }, [parentEvent]) - const selectableRelays = useMemo(() => { - return Array.from(new Set(parentEventSeenOnRelays.concat(relayUrls).concat(favoriteRelays))) - }, [parentEventSeenOnRelays, relayUrls, favoriteRelays]) - const description = useMemo(() => { - if (postTargetItems.length === 0) { - return t('No relays selected') - } - if (postTargetItems.length === 1) { - const item = postTargetItems[0] - if (item.type === 'writeRelays') { - return t('Optimal relays') - } - if (item.type === 'relay') { - return simplifyUrl(item.url) - } - if (item.type === 'relaySet') { - return item.urls.length > 1 - ? t('{{count}} relays', { count: item.urls.length }) - : simplifyUrl(item.urls[0]) - } - } - const hasWriteRelays = postTargetItems.some((item) => item.type === 'writeRelays') - const relayCount = postTargetItems.reduce((count, item) => { - if (item.type === 'relay') { - return count + 1 - } - if (item.type === 'relaySet') { - return count + item.urls.length - } - return count - }, 0) - if (hasWriteRelays) { - return t('Optimal relays and {{count}} other relays', { count: relayCount }) - } - return t('{{count}} relays', { count: relayCount }) - }, [postTargetItems]) - - useEffect(() => { - if (openFrom && openFrom.length) { - setPostTargetItems(Array.from(new Set(openFrom)).map((url) => ({ type: 'relay', url }))) - return - } - if (parentEventSeenOnRelays && parentEventSeenOnRelays.length) { - setPostTargetItems(parentEventSeenOnRelays.map((url) => ({ type: 'relay', url }))) - return - } - setPostTargetItems([{ type: 'writeRelays' }]) - }, [openFrom, parentEventSeenOnRelays]) - - useEffect(() => { - const isProtectedEvent = postTargetItems.every((item) => item.type !== 'writeRelays') - const relayUrls = postTargetItems.flatMap((item) => { - if (item.type === 'relay') { - return [item.url] - } - if (item.type === 'relaySet') { - return item.urls - } - return [] - }) - - setIsProtectedEvent(isProtectedEvent) - setAdditionalRelayUrls(relayUrls) - }, [postTargetItems]) - - const handleWriteRelaysCheckedChange = useCallback((checked: boolean) => { - if (checked) { - setPostTargetItems((prev) => [...prev, { type: 'writeRelays' }]) - } else { - setPostTargetItems((prev) => prev.filter((item) => item.type !== 'writeRelays')) - } - }, []) - - const handleRelayCheckedChange = useCallback((checked: boolean, url: string) => { - if (checked) { - setPostTargetItems((prev) => [...prev, { type: 'relay', url }]) - } else { - setPostTargetItems((prev) => - prev.filter((item) => !(item.type === 'relay' && item.url === url)) - ) - } - }, []) - - const handleRelaySetCheckedChange = useCallback( - (checked: boolean, id: string, urls: string[]) => { - if (checked) { - setPostTargetItems((prev) => [...prev, { type: 'relaySet', id, urls }]) - } else { - setPostTargetItems((prev) => - prev.filter((item) => !(item.type === 'relaySet' && item.id === id)) - ) - } - }, - [] - ) - - const content = useMemo(() => { - return ( - <> - <MenuItem - checked={postTargetItems.some((item) => item.type === 'writeRelays')} - onCheckedChange={handleWriteRelaysCheckedChange} - > - {t('Write relays')} - </MenuItem> - {relaySets.length > 0 && ( - <> - <MenuSeparator /> - {relaySets - .filter(({ relayUrls }) => relayUrls.length) - .map(({ id, name, relayUrls }) => ( - <MenuItem - key={id} - checked={postTargetItems.some( - (item) => item.type === 'relaySet' && item.id === id - )} - onCheckedChange={(checked) => handleRelaySetCheckedChange(checked, id, relayUrls)} - > - <div className="truncate"> - {name} ({relayUrls.length}) - </div> - </MenuItem> - ))} - </> - )} - {selectableRelays.length > 0 && ( - <> - <MenuSeparator /> - {selectableRelays.map((url) => ( - <MenuItem - key={url} - checked={postTargetItems.some((item) => item.type === 'relay' && item.url === url)} - onCheckedChange={(checked) => handleRelayCheckedChange(checked, url)} - > - <div className="flex items-center gap-2"> - <RelayIcon url={url} /> - <div className="truncate">{simplifyUrl(url)}</div> - </div> - </MenuItem> - ))} - </> - )} - </> - ) - }, [postTargetItems, relaySets, selectableRelays]) - - if (isSmallScreen) { - return ( - <> - <div className="flex items-center gap-2"> - <Label>{t('Post to')}</Label> - <Button - variant="outline" - className="px-2 flex-1 max-w-fit justify-start" - onClick={() => setIsDrawerOpen(true)} - > - <div className="truncate">{description}</div> - </Button> - </div> - <Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}> - <DrawerOverlay onClick={() => setIsDrawerOpen(false)} /> - <DrawerContent className="max-h-[80vh]" hideOverlay> - <div - className="overflow-y-auto overscroll-contain py-2" - style={{ touchAction: 'pan-y' }} - > - {content} - </div> - </DrawerContent> - </Drawer> - </> - ) - } - - return ( - <DropdownMenu> - <div className="flex items-center gap-2"> - <Label>{t('Post to')}</Label> - <DropdownMenuTrigger asChild> - <Button variant="outline" className="px-2 flex-1 max-w-fit justify-start"> - <div className="truncate">{description}</div> - </Button> - </DropdownMenuTrigger> - </div> - <DropdownMenuContent align="start" className="max-w-96 max-h-[50vh]" showScrollButtons> - {content} - </DropdownMenuContent> - </DropdownMenu> - ) -} - -function MenuSeparator() { - const { isSmallScreen } = useScreenSize() - if (isSmallScreen) { - return <Separator /> - } - return <DropdownMenuSeparator /> -} - -function MenuItem({ - children, - checked, - onCheckedChange -}: { - children: React.ReactNode - checked: boolean - onCheckedChange: (checked: boolean) => void -}) { - const { isSmallScreen } = useScreenSize() - - if (isSmallScreen) { - return ( - <div - onClick={() => onCheckedChange(!checked)} - className="flex items-center gap-2 px-4 py-3 clickable" - > - <div className="flex items-center justify-center size-4 shrink-0"> - {checked && <Check className="size-4" />} - </div> - {children} - </div> - ) - } - - return ( - <DropdownMenuCheckboxItem - checked={checked} - onSelect={(e) => e.preventDefault()} - onCheckedChange={onCheckedChange} - className="flex items-center gap-2" - > - {children} - </DropdownMenuCheckboxItem> - ) -} diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 4ee9c05b..c10c9657 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -26,6 +26,7 @@ export type TPostTextareaHandle = { appendText: (text: string, addNewline?: boolean) => void insertText: (text: string) => void insertEmoji: (emoji: string | TEmoji) => void + clear: () => void } const PostTextarea = forwardRef< @@ -37,6 +38,7 @@ const PostTextarea = forwardRef< parentStuff?: Event | string onSubmit?: () => void className?: string + fillHeight?: boolean onUploadStart?: (file: File, cancel: () => void) => void onUploadProgress?: (file: File, progress: number) => void onUploadEnd?: (file: File) => void @@ -51,6 +53,7 @@ const PostTextarea = forwardRef< parentStuff, onSubmit, className, + fillHeight = false, onUploadStart, onUploadProgress, onUploadEnd, @@ -154,6 +157,12 @@ const PostTextarea = forwardRef< editor.chain().insertContent(emojiNode).insertContent(' ').run() } } + }, + clear: () => { + if (editor) { + editor.commands.clearContent() + postEditorCache.clearPostCache({ defaultContent, parentStuff }) + } } })) @@ -166,23 +175,24 @@ const PostTextarea = forwardRef< defaultValue="edit" value={tabValue} onValueChange={(v) => setTabValue(v)} - className="space-y-2" + className={cn('space-y-2', fillHeight && 'flex flex-col h-full')} > - <TabsList> + <TabsList className="shrink-0"> <TabsTrigger value="edit">{t('Edit')}</TabsTrigger> <TabsTrigger value="preview">{t('Preview')}</TabsTrigger> </TabsList> - <TabsContent value="edit"> - <EditorContent className="tiptap" editor={editor} /> + <TabsContent value="edit" className={cn(fillHeight && 'flex-1 min-h-0 [&_.tiptap]:h-full')}> + <EditorContent className={cn('tiptap', fillHeight && 'h-full')} editor={editor} /> </TabsContent> <TabsContent value="preview" + className={cn(fillHeight && 'flex-1 min-h-0 overflow-auto')} onClick={() => { setTabValue('edit') editor.commands.focus() }} > - <Preview content={text} className={className} /> + <Preview content={text} className={cn(className, fillHeight && 'h-full')} /> </TabsContent> </Tabs> ) diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index 3c3c29bc..3e4e7a76 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -15,10 +15,11 @@ import { } from '@/components/ui/sheet' import { useScreenSize } from '@/providers/ScreenSizeProvider' import postEditor from '@/services/post-editor.service' +import { ArrowLeft } from 'lucide-react' import { Event } from 'nostr-tools' -import { Dispatch, useMemo } from 'react' +import { Dispatch, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import PostContent from './PostContent' +import PostContent, { TPostContentHandle } from './PostContent' import Title from './Title' export default function PostEditor({ @@ -26,26 +27,29 @@ export default function PostEditor({ parentStuff, open, setOpen, - openFrom, highlightedText }: { defaultContent?: string parentStuff?: Event | string open: boolean setOpen: Dispatch<boolean> - openFrom?: string[] highlightedText?: string }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() + const contentRef = useRef<TPostContentHandle>(null) + + const handleReset = () => { + contentRef.current?.reset() + } const content = useMemo(() => { return ( <PostContent + ref={contentRef} defaultContent={defaultContent} parentStuff={parentStuff} close={() => setOpen(false)} - openFrom={openFrom} highlightedText={highlightedText} /> ) @@ -55,7 +59,7 @@ export default function PostEditor({ return ( <Sheet open={open} onOpenChange={setOpen}> <SheetContent - className="h-full w-full p-0 border-none" + className="h-[100dvh] w-full p-0 border-none flex flex-col" side="bottom" hideClose onEscapeKeyDown={(e) => { @@ -65,17 +69,29 @@ export default function PostEditor({ } }} > - <ScrollArea className="px-4 h-full max-h-screen"> - <div className="space-y-4 px-2 py-6"> - <SheetHeader> - <SheetTitle className="text-start"> - {highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />} - </SheetTitle> - <SheetDescription className="hidden" /> - </SheetHeader> - {content} - </div> - </ScrollArea> + <div className="flex items-center h-12 border-b shrink-0"> + <button + onClick={() => setOpen(false)} + className="flex items-center justify-center w-10 h-full hover:bg-accent transition-colors" + aria-label="Close" + > + <ArrowLeft className="w-5 h-5" /> + </button> + <SheetHeader className="flex-1"> + <SheetTitle className="text-start text-base font-medium"> + {highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />} + </SheetTitle> + <SheetDescription className="hidden" /> + </SheetHeader> + <button + onClick={handleReset} + className="flex items-center justify-center w-10 h-full hover:bg-accent transition-colors" + aria-label="Reset" + > + 🧹 + </button> + </div> + <div className="flex flex-col flex-1 min-h-0 px-4 py-4">{content}</div> </SheetContent> </Sheet> ) @@ -95,10 +111,17 @@ export default function PostEditor({ > <ScrollArea className="px-4 h-full max-h-screen"> <div className="space-y-4 px-2 py-6"> - <DialogHeader> + <DialogHeader className="flex flex-row items-center justify-between"> <DialogTitle> {highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />} </DialogTitle> + <button + onClick={handleReset} + className="flex items-center justify-center w-8 h-8 rounded hover:bg-accent transition-colors" + aria-label="Reset" + > + 🧹 + </button> <DialogDescription className="hidden" /> </DialogHeader> {content} diff --git a/src/components/RelayInfo/index.tsx b/src/components/RelayInfo/index.tsx index ce95d90d..978ae224 100644 --- a/src/components/RelayInfo/index.tsx +++ b/src/components/RelayInfo/index.tsx @@ -120,7 +120,7 @@ export default function RelayInfo({ url, className }: { url: string; className?: > {t('Share something on this Relay')} </Button> - <PostEditor open={open} setOpen={setOpen} openFrom={[relayInfo.url]} /> + <PostEditor open={open} setOpen={setOpen} /> </> )} </div> diff --git a/src/components/SearchOverlay/index.tsx b/src/components/SearchOverlay/index.tsx new file mode 100644 index 00000000..dc2fa884 --- /dev/null +++ b/src/components/SearchOverlay/index.tsx @@ -0,0 +1,61 @@ +import SearchBar, { TSearchBarRef } from '@/components/SearchBar' +import SearchResult from '@/components/SearchResult' +import { TSearchParams } from '@/types' +import { ArrowLeft } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' + +type SearchOverlayProps = { + open: boolean + onClose: () => void +} + +export default function SearchOverlay({ open, onClose }: SearchOverlayProps) { + const [input, setInput] = useState('') + const [searchParams, setSearchParams] = useState<TSearchParams | null>(null) + const searchBarRef = useRef<TSearchBarRef>(null) + const contentRef = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (open) { + // Focus search bar when overlay opens + setTimeout(() => searchBarRef.current?.focus(), 100) + } else { + // Reset state when overlay closes + setInput('') + setSearchParams(null) + } + }, [open]) + + const onSearch = (params: TSearchParams | null) => { + setSearchParams(params) + if (params?.input) { + setInput(params.input) + } + contentRef.current?.scrollTo({ top: 0, behavior: 'instant' }) + } + + if (!open) return null + + return ( + <div className="fixed inset-0 z-50 bg-background flex flex-col"> + {/* Header with back button and search bar */} + <div className="flex items-center h-12 border-b bg-background"> + <button + onClick={onClose} + className="flex items-center justify-center w-12 h-full hover:bg-accent transition-colors" + aria-label="Close search" + > + <ArrowLeft className="w-5 h-5" /> + </button> + <div className="flex-1 h-full pr-3"> + <SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} /> + </div> + </div> + + {/* Search results */} + <div ref={contentRef} className="flex-1 overflow-y-auto bg-background"> + <SearchResult searchParams={searchParams} /> + </div> + </div> + ) +} diff --git a/src/components/Settings/index.tsx b/src/components/Settings/index.tsx index 90906dc2..558b7841 100644 --- a/src/components/Settings/index.tsx +++ b/src/components/Settings/index.tsx @@ -52,7 +52,7 @@ import { useTheme } from '@/providers/ThemeProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import { useZap } from '@/providers/ZapProvider' -import storage from '@/services/local-storage.service' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types' import { disconnect, launchModal } from '@getalby/bitcoin-connect-react' import { @@ -77,7 +77,7 @@ import { Wallet } from 'lucide-react' import { kinds } from 'nostr-tools' -import { forwardRef, HTMLProps, useCallback, useRef, useState } from 'react' +import { forwardRef, HTMLProps, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' type TEmojiTab = 'my-packs' | 'explore' @@ -106,7 +106,6 @@ export default function Settings() { const [copiedNsec, setCopiedNsec] = useState(false) const [copiedNcryptsec, setCopiedNcryptsec] = useState(false) const [openSection, setOpenSection] = useState<string>('') - const accordionRef = useRef<HTMLDivElement>(null) // General settings const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage) @@ -162,29 +161,16 @@ export default function Settings() { } const handleAccordionChange = useCallback((value: string) => { + // Prevent auto-scroll when opening accordion sections + const scrollY = window.scrollY setOpenSection(value) - if (value) { - // Scroll the opened section into view - setTimeout(() => { - const item = accordionRef.current?.querySelector(`[data-state="open"]`) - if (item) { - const rect = item.getBoundingClientRect() - const scrollContainer = accordionRef.current?.closest('[data-radix-scroll-area-viewport]') || window - if (scrollContainer === window) { - const scrollTop = window.scrollY + rect.top - 16 - window.scrollTo({ top: scrollTop, behavior: 'smooth' }) - } else { - const containerRect = (scrollContainer as HTMLElement).getBoundingClientRect() - const scrollTop = (scrollContainer as HTMLElement).scrollTop + rect.top - containerRect.top - 16 - ;(scrollContainer as HTMLElement).scrollTo({ top: scrollTop, behavior: 'smooth' }) - } - } - }, 50) - } + requestAnimationFrame(() => { + window.scrollTo(0, scrollY) + }) }, []) return ( - <div ref={accordionRef}> + <div> <Accordion type="single" collapsible @@ -573,6 +559,7 @@ export default function Settings() { onCheckedChange={(checked) => { storage.setFilterOutOnionRelays(checked) setFilterOutOnionRelays(checked) + dispatchSettingsChanged() }} /> </SettingItem> diff --git a/src/components/Sidebar/AccountButton.tsx b/src/components/Sidebar/AccountButton.tsx index 26e905f1..d1e2de64 100644 --- a/src/components/Sidebar/AccountButton.tsx +++ b/src/components/Sidebar/AccountButton.tsx @@ -9,8 +9,9 @@ import { } from '@/components/ui/dropdown-menu' import { toWallet } from '@/lib/link' import { cn } from '@/lib/utils' -import { useSecondaryPage } from '@/PageManager' +import { useSecondaryPage, useSidebarDrawer } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' +import { useScreenSize } from '@/providers/ScreenSizeProvider' import { LogIn, LogOut, Plus, Wallet } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -36,12 +37,36 @@ function ProfileButton({ collapse }: { collapse: boolean }) { const { account, accounts, switchAccount } = useNostr() const pubkey = account?.pubkey const { push } = useSecondaryPage() + const { isSmallScreen } = useScreenSize() + const { close: closeSidebarDrawer } = useSidebarDrawer() + const [dropdownOpen, setDropdownOpen] = useState(false) const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) if (!pubkey) return null + const handleOpenLoginDialog = () => { + setDropdownOpen(false) + if (isSmallScreen) { + closeSidebarDrawer() + setTimeout(() => setLoginDialogOpen(true), 150) + } else { + setLoginDialogOpen(true) + } + } + + const handleOpenLogoutDialog = () => { + setDropdownOpen(false) + if (isSmallScreen) { + closeSidebarDrawer() + setTimeout(() => setLogoutDialogOpen(true), 150) + } else { + setLogoutDialogOpen(true) + } + } + return ( - <DropdownMenu> + <> + <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenuTrigger asChild> <Button variant="ghost" @@ -95,7 +120,7 @@ function ProfileButton({ collapse }: { collapse: boolean }) { </DropdownMenuItem> ))} <DropdownMenuItem - onClick={() => setLoginDialogOpen(true)} + onClick={handleOpenLoginDialog} className="border border-dashed m-2 focus:border-muted-foreground focus:bg-background" > <div className="flex gap-2 items-center justify-center w-full py-2"> @@ -105,7 +130,7 @@ function ProfileButton({ collapse }: { collapse: boolean }) { </DropdownMenuItem> <DropdownMenuItem className="text-destructive focus:text-destructive" - onClick={() => setLogoutDialogOpen(true)} + onClick={handleOpenLogoutDialog} > <LogOut /> <span className="shrink-0">{t('Logout')}</span> @@ -115,9 +140,10 @@ function ProfileButton({ collapse }: { collapse: boolean }) { /> </DropdownMenuItem> </DropdownMenuContent> - <LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} /> - <LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} /> </DropdownMenu> + <LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} /> + <LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} /> + </> ) } diff --git a/src/components/Sidebar/ExploreButton.tsx b/src/components/Sidebar/ExploreButton.tsx deleted file mode 100644 index 0645c331..00000000 --- a/src/components/Sidebar/ExploreButton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { usePrimaryPage } from '@/PageManager' -import { Compass } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import SidebarItem from './SidebarItem' - -export default function RelaysButton({ collapse }: { collapse: boolean }) { - const { t } = useTranslation() - const { navigate, current, display } = usePrimaryPage() - - return ( - <SidebarItem - title={t('Explore')} - onClick={() => navigate('explore')} - active={display && current === 'explore'} - collapse={collapse} - > - <Compass /> - </SidebarItem> - ) -} diff --git a/src/components/Sidebar/LayoutSwitcher.tsx b/src/components/Sidebar/LayoutSwitcher.tsx index 1e7e6b64..f20b5735 100644 --- a/src/components/Sidebar/LayoutSwitcher.tsx +++ b/src/components/Sidebar/LayoutSwitcher.tsx @@ -1,10 +1,16 @@ import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { Columns2, PanelLeft } from 'lucide-react' export default function LayoutSwitcher({ collapse }: { collapse: boolean }) { const { enableSingleColumnLayout, updateEnableSingleColumnLayout } = useUserPreferences() + const { canUseDoublePane } = useScreenSize() + + if (!canUseDoublePane) { + return null + } if (collapse) { return ( diff --git a/src/components/Sidebar/LogoutButton.tsx b/src/components/Sidebar/LogoutButton.tsx new file mode 100644 index 00000000..c74e31e8 --- /dev/null +++ b/src/components/Sidebar/LogoutButton.tsx @@ -0,0 +1,81 @@ +import { cn } from '@/lib/utils' +import { useNostr } from '@/providers/NostrProvider' +import { LogOut } from 'lucide-react' +import { useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import SidebarItem from './SidebarItem' + +const HOLD_DURATION = 3000 + +export default function LogoutButton({ collapse }: { collapse: boolean }) { + const { t } = useTranslation() + const { account, removeAccount } = useNostr() + const [progress, setProgress] = useState(0) + const [isHolding, setIsHolding] = useState(false) + const animationRef = useRef<number | null>(null) + const startTimeRef = useRef<number | null>(null) + + const startHold = useCallback(() => { + if (!account) return + setIsHolding(true) + startTimeRef.current = Date.now() + + const animate = () => { + if (!startTimeRef.current) return + const elapsed = Date.now() - startTimeRef.current + const newProgress = Math.min((elapsed / HOLD_DURATION) * 100, 100) + setProgress(newProgress) + + if (newProgress >= 100) { + // Logout triggered + removeAccount(account) + setProgress(0) + setIsHolding(false) + startTimeRef.current = null + return + } + + animationRef.current = requestAnimationFrame(animate) + } + + animationRef.current = requestAnimationFrame(animate) + }, [account, removeAccount]) + + const cancelHold = useCallback(() => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + animationRef.current = null + } + startTimeRef.current = null + setProgress(0) + setIsHolding(false) + }, []) + + if (!account) return null + + return ( + <div className="relative"> + <SidebarItem + title={t('Logout')} + collapse={collapse} + onTouchStart={startHold} + onTouchEnd={cancelHold} + onTouchCancel={cancelHold} + onMouseDown={startHold} + onMouseUp={cancelHold} + onMouseLeave={cancelHold} + className={cn('select-none', isHolding && 'text-destructive')} + > + <LogOut /> + </SidebarItem> + {isHolding && ( + <div className="absolute bottom-0 left-0 right-0 h-1 bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-destructive transition-none" + style={{ width: `${progress}%` }} + /> + </div> + )} + </div> + ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index b3d4ed13..24664bc7 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -9,7 +9,6 @@ import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { ChevronsLeft, ChevronsRight } from 'lucide-react' import AccountButton from './AccountButton' import BookmarkButton from './BookmarkButton' -import RelaysButton from './ExploreButton' import HomeButton from './HomeButton' import LayoutSwitcher from './LayoutSwitcher' import NotificationsButton from './NotificationButton' @@ -19,7 +18,7 @@ import SearchButton from './SearchButton' import SettingsButton from './SettingsButton' export default function PrimaryPageSidebar() { - const { isSmallScreen } = useScreenSize() + const { isSmallScreen, isNarrowDesktop } = useScreenSize() const { themeSetting } = useTheme() const { sidebarCollapse, updateSidebarCollapse, enableSingleColumnLayout } = useUserPreferences() const { pubkey } = useNostr() @@ -27,15 +26,18 @@ export default function PrimaryPageSidebar() { if (isSmallScreen) return null + // Force collapsed mode in narrow desktop (768-1024px) + const isCollapsed = isNarrowDesktop || sidebarCollapse + return ( <div className={cn( - 'relative flex flex-col pb-2 pt-3 justify-between h-full shrink-0', - sidebarCollapse ? 'px-2 w-16' : 'px-4 w-52' + 'relative flex flex-col pb-2 pt-3 justify-between h-full shrink-0 bg-chrome-background', + isCollapsed ? 'px-2 w-16' : 'px-4 w-52' )} > <div className="space-y-2"> - {sidebarCollapse ? ( + {isCollapsed ? ( <button className="px-3 py-1 mb-4 w-full cursor-pointer hover:opacity-80 transition-opacity" onClick={() => navigate('home')} @@ -52,31 +54,32 @@ export default function PrimaryPageSidebar() { <Logo /> </button> )} - <HomeButton collapse={sidebarCollapse} /> - <RelaysButton collapse={sidebarCollapse} /> - <NotificationsButton collapse={sidebarCollapse} /> - <SearchButton collapse={sidebarCollapse} /> - <ProfileButton collapse={sidebarCollapse} /> - {pubkey && <BookmarkButton collapse={sidebarCollapse} />} - <SettingsButton collapse={sidebarCollapse} /> - <PostButton collapse={sidebarCollapse} /> + <HomeButton collapse={isCollapsed} /> + <NotificationsButton collapse={isCollapsed} /> + <SearchButton collapse={isCollapsed} /> + <ProfileButton collapse={isCollapsed} /> + {pubkey && <BookmarkButton collapse={isCollapsed} />} + <SettingsButton collapse={isCollapsed} /> + <PostButton collapse={isCollapsed} /> </div> <div className="space-y-4"> - <LayoutSwitcher collapse={sidebarCollapse} /> - <AccountButton collapse={sidebarCollapse} /> + <LayoutSwitcher collapse={isCollapsed} /> + <AccountButton collapse={isCollapsed} /> </div> - <button - className={cn( - 'absolute flex flex-col justify-center items-center right-0 w-5 h-6 p-0 rounded-l-md hover:shadow-md text-muted-foreground hover:text-foreground hover:bg-background transition-colors [&_svg]:size-4', - themeSetting === 'pure-black' || enableSingleColumnLayout ? 'top-3' : 'top-5' - )} - onClick={(e) => { - e.stopPropagation() - updateSidebarCollapse(!sidebarCollapse) - }} - > - {sidebarCollapse ? <ChevronsRight /> : <ChevronsLeft />} - </button> + {!isNarrowDesktop && ( + <button + className={cn( + 'absolute flex flex-col justify-center items-center right-0 w-5 h-6 p-0 rounded-l-md hover:shadow-md text-muted-foreground hover:text-foreground hover:bg-background transition-colors [&_svg]:size-4', + themeSetting === 'pure-black' || enableSingleColumnLayout ? 'top-3' : 'top-5' + )} + onClick={(e) => { + e.stopPropagation() + updateSidebarCollapse(!sidebarCollapse) + }} + > + {sidebarCollapse ? <ChevronsRight /> : <ChevronsLeft />} + </button> + )} </div> ) } diff --git a/src/components/SidebarDrawer/index.tsx b/src/components/SidebarDrawer/index.tsx new file mode 100644 index 00000000..3c82d0ff --- /dev/null +++ b/src/components/SidebarDrawer/index.tsx @@ -0,0 +1,95 @@ +import GiteaIcon from '@/assets/GiteaIcon' +import Logo from '@/assets/Logo' +import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' +import { usePrimaryPage } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' +import { VisuallyHidden } from '@radix-ui/react-visually-hidden' +import AccountButton from '../Sidebar/AccountButton' +import BookmarkButton from '../Sidebar/BookmarkButton' +import HomeButton from '../Sidebar/HomeButton' +import LogoutButton from '../Sidebar/LogoutButton' +import NotificationsButton from '../Sidebar/NotificationButton' +import ProfileButton from '../Sidebar/ProfileButton' +import SettingsButton from '../Sidebar/SettingsButton' + +type SidebarDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +export default function SidebarDrawer({ open, onOpenChange }: SidebarDrawerProps) { + const { pubkey } = useNostr() + const { navigate } = usePrimaryPage() + + const handleItemClick = () => { + onOpenChange(false) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent + side="left" + hideClose + className="w-64 p-0 bg-chrome-background border-r-0 rounded-r-none" + > + <VisuallyHidden> + <SheetTitle>Navigation Menu</SheetTitle> + <SheetDescription>App navigation and account menu</SheetDescription> + </VisuallyHidden> + <div className="flex flex-col h-full pb-4 pt-3 px-4 justify-between"> + {/* Account at top */} + <div className="space-y-4"> + <div onClick={handleItemClick}> + <AccountButton collapse={false} /> + </div> + </div> + + {/* Navigation items in the middle */} + <div className="space-y-2 flex-1 py-4"> + <div onClick={handleItemClick}> + <HomeButton collapse={false} /> + </div> + <div onClick={handleItemClick}> + <NotificationsButton collapse={false} /> + </div> + <div onClick={handleItemClick}> + <ProfileButton collapse={false} /> + </div> + {pubkey && ( + <div onClick={handleItemClick}> + <BookmarkButton collapse={false} /> + </div> + )} + <div onClick={handleItemClick}> + <SettingsButton collapse={false} /> + </div> + {pubkey && <LogoutButton collapse={false} />} + </div> + + {/* Logo and version at bottom */} + <div className="space-y-2"> + <button + className="px-4 w-full cursor-pointer hover:opacity-80 transition-opacity" + onClick={() => { + navigate('home') + handleItemClick() + }} + aria-label="Go to home" + > + <Logo /> + </button> + <a + href="https://git.mleku.dev/mleku/smesh" + target="_blank" + rel="noopener noreferrer" + className="flex items-center justify-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors" + > + <GiteaIcon className="w-4 h-4" /> + <span>v{import.meta.env.APP_VERSION}</span> + </a> + </div> + </div> + </SheetContent> + </Sheet> + ) +} diff --git a/src/components/SignerTypeBadge/index.tsx b/src/components/SignerTypeBadge/index.tsx index 1117c86e..4772dd41 100644 --- a/src/components/SignerTypeBadge/index.tsx +++ b/src/components/SignerTypeBadge/index.tsx @@ -7,8 +7,6 @@ export default function SignerTypeBadge({ signerType }: { signerType: TSignerTyp if (signerType === 'nip-07') { return <Badge className=" bg-green-400 hover:bg-green-400 px-1 py-0">{t('Extension')}</Badge> - } else if (signerType === 'bunker') { - return <Badge className=" bg-blue-400 hover:bg-blue-400 px-1 py-0">{t('Remote')}</Badge> } else if (signerType === 'ncryptsec') { return ( <Badge className="bg-violet-400 hover:bg-violet-400 px-1 py-0">{t('Encrypted Key')}</Badge> diff --git a/src/components/Titlebar/index.tsx b/src/components/Titlebar/index.tsx index 65fa2a0f..8d548ed8 100644 --- a/src/components/Titlebar/index.tsx +++ b/src/components/Titlebar/index.tsx @@ -1,23 +1,84 @@ +import iconDark from '@/assets/smeshicondark.png' +import iconLight from '@/assets/smeshiconlight.png' import { cn } from '@/lib/utils' +import { useSidebarDrawer } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' +import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useTheme } from '@/providers/ThemeProvider' +import { PencilLine, Search } from 'lucide-react' +import { useState } from 'react' +import PostEditor from '../PostEditor' +import SearchOverlay from '../SearchOverlay' export function Titlebar({ children, className, - hideBottomBorder = false + hideBottomBorder = false, + hideMenuButton = false }: { children?: React.ReactNode className?: string hideBottomBorder?: boolean + hideMenuButton?: boolean }) { + const { isSmallScreen } = useScreenSize() + return ( <div className={cn( - 'sticky top-0 w-full h-12 z-40 bg-background [&_svg]:size-5 [&_svg]:shrink-0 select-none', + 'sticky top-0 w-full h-12 z-40 [&_svg]:size-5 [&_svg]:shrink-0 select-none bg-background', !hideBottomBorder && 'border-b', className )} > - {children} + <div className="flex items-center h-full w-full"> + {isSmallScreen && !hideMenuButton && <MenuButton />} + <div className="flex-1 h-full">{children}</div> + {isSmallScreen && <TitlebarActions />} + </div> + </div> + ) +} + +function MenuButton() { + const { toggle } = useSidebarDrawer() + const { theme } = useTheme() + const iconSrc = theme === 'light' ? iconLight : iconDark + + return ( + <button + onClick={toggle} + className="flex items-center justify-center w-10 h-full hover:bg-accent transition-colors" + aria-label="Open menu" + > + <img src={iconSrc} alt="Menu" className="w-6 h-6" /> + </button> + ) +} + +function TitlebarActions() { + const { checkLogin } = useNostr() + const [postEditorOpen, setPostEditorOpen] = useState(false) + const [searchOpen, setSearchOpen] = useState(false) + + return ( + <div className="flex items-center h-full"> + <button + onClick={() => setSearchOpen(true)} + className="flex items-center justify-center w-10 h-full hover:bg-accent transition-colors" + aria-label="Search" + > + <Search className="w-5 h-5" /> + </button> + <button + onClick={() => checkLogin(() => setPostEditorOpen(true))} + className="flex items-center justify-center w-10 h-full hover:bg-accent transition-colors" + aria-label="Compose" + > + <PencilLine className="w-5 h-5" /> + </button> + <PostEditor open={postEditorOpen} setOpen={setPostEditorOpen} /> + <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} /> </div> ) } diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx index c86f33d2..b6daf14b 100644 --- a/src/components/ui/accordion.tsx +++ b/src/components/ui/accordion.tsx @@ -18,7 +18,7 @@ const AccordionTrigger = React.forwardRef< React.ComponentRef<typeof AccordionPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> >(({ className, children, ...props }, ref) => ( - <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Header className="flex scroll-mt-14"> <AccordionPrimitive.Trigger ref={ref} className={cn( diff --git a/src/constants.ts b/src/constants.ts index f0b9db69..f8af2f9c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -57,21 +57,24 @@ export const StorageKey = { } export const ApplicationDataKey = { - NOTIFICATIONS_SEEN_AT: 'seen_notifications_at' + NOTIFICATIONS_SEEN_AT: 'seen_notifications_at', + SETTINGS: 'smesh_settings' } export const BIG_RELAY_URLS = [ 'wss://relay.damus.io/', 'wss://nos.lol/', 'wss://relay.primal.net/', - 'wss://offchain.pub/' + 'wss://offchain.pub/', + 'wss://relay.orly.dev/' ] export const SEARCHABLE_RELAY_URLS = [ 'wss://search.nos.today/', 'wss://relay.ditto.pub/', 'wss://relay.nostrcheck.me/', - 'wss://relay.nostr.band/' + 'wss://relay.nostr.band/', + 'wss://relay.orly.dev/' ] export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/'] @@ -152,12 +155,6 @@ export const NIP_96_SERVICE = [ ] export const DEFAULT_NIP_96_SERVICE = 'https://nostr.build' -export const DEFAULT_NOSTRCONNECT_RELAY = [ - 'wss://relay.nsec.app/', - 'wss://bucket.coracle.social/', - 'wss://relay.primal.net/' -] - export const DEFAULT_FAVICON_URL_TEMPLATE = 'https://{hostname}/favicon.ico' export const POLL_TYPE = { diff --git a/src/domain/content/Note.ts b/src/domain/content/Note.ts new file mode 100644 index 00000000..af92f0fa --- /dev/null +++ b/src/domain/content/Note.ts @@ -0,0 +1,257 @@ +import { Event, kinds } from 'nostr-tools' +import { EventId, Pubkey, Timestamp } from '../shared' + +/** + * Types of notes based on their relationship to other content + */ +export type NoteType = 'root' | 'reply' | 'quote' + +/** + * Mention extracted from note tags + */ +export type NoteMention = { + pubkey: Pubkey + relayHint?: string + marker?: 'reply' | 'root' | 'mention' +} + +/** + * Reference to another note + */ +export type NoteReference = { + eventId: EventId + relayHint?: string + marker?: 'reply' | 'root' | 'mention' + author?: Pubkey +} + +/** + * Note Entity + * + * Represents a short text note (kind 1) in Nostr. + * Wraps the raw Event with rich domain behavior. + * + * This is a read-only entity - it represents a published note. + * For creating notes, use NoteBuilder. + */ +export class Note { + private readonly _mentions: NoteMention[] + private readonly _references: NoteReference[] + private readonly _hashtags: string[] + + private constructor( + private readonly _event: Event, + mentions: NoteMention[], + references: NoteReference[], + hashtags: string[] + ) { + this._mentions = mentions + this._references = references + this._hashtags = hashtags + } + + /** + * Create a Note from a Nostr Event + */ + static fromEvent(event: Event): Note { + if (event.kind !== kinds.ShortTextNote) { + throw new Error(`Expected kind ${kinds.ShortTextNote}, got ${event.kind}`) + } + + const mentions: NoteMention[] = [] + const references: NoteReference[] = [] + const hashtags: string[] = [] + + for (const tag of event.tags) { + if (tag[0] === 'p' && tag[1]) { + const pubkey = Pubkey.tryFromString(tag[1]) + if (pubkey) { + mentions.push({ + pubkey, + relayHint: tag[2] || undefined, + marker: tag[3] as NoteMention['marker'] + }) + } + } else if (tag[0] === 'e' && tag[1]) { + const eventId = EventId.tryFromString(tag[1]) + if (eventId) { + const author = tag[4] ? Pubkey.tryFromString(tag[4]) : undefined + references.push({ + eventId, + relayHint: tag[2] || undefined, + marker: tag[3] as NoteReference['marker'], + author: author || undefined + }) + } + } else if (tag[0] === 't' && tag[1]) { + hashtags.push(tag[1].toLowerCase()) + } + } + + return new Note(event, mentions, references, hashtags) + } + + /** + * Try to create a Note from an Event, returns null if invalid + */ + static tryFromEvent(event: Event | null | undefined): Note | null { + if (!event) return null + try { + return Note.fromEvent(event) + } catch { + return null + } + } + + /** + * The underlying Nostr event + */ + get event(): Event { + return this._event + } + + /** + * The note's event ID + */ + get id(): EventId { + return EventId.fromHex(this._event.id) + } + + /** + * The author's public key + */ + get author(): Pubkey { + return Pubkey.fromHex(this._event.pubkey) + } + + /** + * The note content + */ + get content(): string { + return this._event.content + } + + /** + * When the note was created + */ + get createdAt(): Timestamp { + return Timestamp.fromUnix(this._event.created_at) + } + + /** + * All mentioned users + */ + get mentions(): NoteMention[] { + return [...this._mentions] + } + + /** + * All referenced notes + */ + get references(): NoteReference[] { + return [...this._references] + } + + /** + * All hashtags in the note + */ + get hashtags(): string[] { + return [...this._hashtags] + } + + /** + * Get the type of note based on its references + */ + get noteType(): NoteType { + const rootRef = this._references.find((r) => r.marker === 'root') + const replyRef = this._references.find((r) => r.marker === 'reply') + const quoteRef = this._event.tags.find((t) => t[0] === 'q') + + if (rootRef || replyRef) { + return 'reply' + } + if (quoteRef) { + return 'quote' + } + return 'root' + } + + /** + * Whether this is a root note (not a reply) + */ + get isRoot(): boolean { + return this.noteType === 'root' + } + + /** + * Whether this is a reply to another note + */ + get isReply(): boolean { + return this.noteType === 'reply' + } + + /** + * Get the root note reference (if this is a reply) + */ + get rootReference(): NoteReference | undefined { + return this._references.find((r) => r.marker === 'root') + } + + /** + * Get the parent note reference (if this is a reply) + */ + get parentReference(): NoteReference | undefined { + return this._references.find((r) => r.marker === 'reply') + } + + /** + * Whether this note has a content warning + */ + get hasContentWarning(): boolean { + return this._event.tags.some((t) => t[0] === 'content-warning') + } + + /** + * Get the content warning reason (if any) + */ + get contentWarning(): string | undefined { + const tag = this._event.tags.find((t) => t[0] === 'content-warning') + return tag?.[1] + } + + /** + * Whether this is an NSFW note + */ + get isNsfw(): boolean { + const cwTag = this._event.tags.find((t) => t[0] === 'content-warning') + return cwTag?.[1]?.toLowerCase().includes('nsfw') ?? false + } + + /** + * Whether this note mentions a specific user + */ + mentionsUser(pubkey: Pubkey): boolean { + return this._mentions.some((m) => m.pubkey.equals(pubkey)) + } + + /** + * Whether this note references a specific event + */ + referencesNote(eventId: EventId): boolean { + return this._references.some((r) => r.eventId.equals(eventId)) + } + + /** + * Whether this note includes a specific hashtag + */ + hasHashtag(hashtag: string): boolean { + return this._hashtags.includes(hashtag.toLowerCase()) + } + + /** + * Get mentioned pubkeys as hex strings (for legacy compatibility) + */ + getMentionedPubkeysHex(): string[] { + return this._mentions.map((m) => m.pubkey.hex) + } +} diff --git a/src/domain/content/Reaction.ts b/src/domain/content/Reaction.ts new file mode 100644 index 00000000..098d7bce --- /dev/null +++ b/src/domain/content/Reaction.ts @@ -0,0 +1,202 @@ +import { Event, kinds } from 'nostr-tools' +import { EventId, Pubkey, Timestamp } from '../shared' + +/** + * Type of reaction + */ +export type ReactionType = 'like' | 'dislike' | 'emoji' | 'custom_emoji' + +/** + * Custom emoji data + */ +export type CustomEmoji = { + shortcode: string + url: string +} + +/** + * Reaction Entity + * + * Represents a reaction (kind 7) to a Nostr event. + * Reactions can be likes (+), dislikes (-), emojis, or custom emojis. + */ +export class Reaction { + private constructor( + private readonly _event: Event, + private readonly _targetEventId: EventId, + private readonly _targetAuthor: Pubkey, + private readonly _type: ReactionType, + private readonly _emoji: string, + private readonly _customEmoji?: CustomEmoji + ) {} + + /** + * Create a Reaction from a Nostr Event + */ + static fromEvent(event: Event): Reaction { + if (event.kind !== kinds.Reaction) { + throw new Error(`Expected kind ${kinds.Reaction}, got ${event.kind}`) + } + + // Find the target event (last 'e' tag) + const eTags = event.tags.filter((t) => t[0] === 'e') + const lastETag = eTags[eTags.length - 1] + if (!lastETag?.[1]) { + throw new Error('Reaction must have an e tag') + } + + // Find the target author (last 'p' tag) + const pTags = event.tags.filter((t) => t[0] === 'p') + const lastPTag = pTags[pTags.length - 1] + if (!lastPTag?.[1]) { + throw new Error('Reaction must have a p tag') + } + + const targetEventId = EventId.fromHex(lastETag[1]) + const targetAuthor = Pubkey.fromHex(lastPTag[1]) + + // Determine reaction type + const content = event.content + let type: ReactionType + let emoji = content + let customEmoji: CustomEmoji | undefined + + if (content === '+' || content === '') { + type = 'like' + emoji = '+' + } else if (content === '-') { + type = 'dislike' + } else if (content.startsWith(':') && content.endsWith(':')) { + // Custom emoji format :shortcode: + const emojiTag = event.tags.find( + (t) => t[0] === 'emoji' && t[1] === content.slice(1, -1) + ) + if (emojiTag?.[2]) { + type = 'custom_emoji' + customEmoji = { + shortcode: emojiTag[1], + url: emojiTag[2] + } + } else { + type = 'emoji' + } + } else { + type = 'emoji' + } + + return new Reaction(event, targetEventId, targetAuthor, type, emoji, customEmoji) + } + + /** + * Try to create a Reaction from an Event, returns null if invalid + */ + static tryFromEvent(event: Event | null | undefined): Reaction | null { + if (!event) return null + try { + return Reaction.fromEvent(event) + } catch { + return null + } + } + + /** + * The underlying Nostr event + */ + get event(): Event { + return this._event + } + + /** + * The reaction's event ID + */ + get id(): EventId { + return EventId.fromHex(this._event.id) + } + + /** + * The author of the reaction + */ + get author(): Pubkey { + return Pubkey.fromHex(this._event.pubkey) + } + + /** + * The event ID being reacted to + */ + get targetEventId(): EventId { + return this._targetEventId + } + + /** + * The author of the event being reacted to + */ + get targetAuthor(): Pubkey { + return this._targetAuthor + } + + /** + * The type of reaction + */ + get type(): ReactionType { + return this._type + } + + /** + * The emoji or reaction content + */ + get emoji(): string { + return this._emoji + } + + /** + * Custom emoji data (if applicable) + */ + get customEmoji(): CustomEmoji | undefined { + return this._customEmoji + } + + /** + * When the reaction was created + */ + get createdAt(): Timestamp { + return Timestamp.fromUnix(this._event.created_at) + } + + /** + * Whether this is a like + */ + get isLike(): boolean { + return this._type === 'like' + } + + /** + * Whether this is a dislike + */ + get isDislike(): boolean { + return this._type === 'dislike' + } + + /** + * Whether this is a positive reaction (like or emoji, not dislike) + */ + get isPositive(): boolean { + return this._type !== 'dislike' + } + + /** + * Whether this uses a custom emoji + */ + get hasCustomEmoji(): boolean { + return this._type === 'custom_emoji' && !!this._customEmoji + } + + /** + * Get the display value for the reaction + */ + get displayValue(): string { + if (this._type === 'like') return '❤️' + if (this._type === 'dislike') return '👎' + if (this._customEmoji) return `:${this._customEmoji.shortcode}:` + return this._emoji + } +} diff --git a/src/domain/content/Repost.ts b/src/domain/content/Repost.ts new file mode 100644 index 00000000..ca53dc1e --- /dev/null +++ b/src/domain/content/Repost.ts @@ -0,0 +1,146 @@ +import { Event, kinds } from 'nostr-tools' +import { EventId, Pubkey, Timestamp } from '../shared' + +/** + * Repost Entity + * + * Represents a repost (kind 6 or 16) of another Nostr event. + */ +export class Repost { + private readonly _embeddedEvent: Event | null + + private constructor( + private readonly _event: Event, + private readonly _targetEventId: EventId, + private readonly _targetAuthor: Pubkey, + embeddedEvent: Event | null + ) { + this._embeddedEvent = embeddedEvent + } + + /** + * Create a Repost from a Nostr Event + */ + static fromEvent(event: Event): Repost { + if (event.kind !== kinds.Repost && event.kind !== kinds.GenericRepost) { + throw new Error(`Expected kind ${kinds.Repost} or ${kinds.GenericRepost}, got ${event.kind}`) + } + + // Find the target event (first 'e' tag) + const eTag = event.tags.find((t) => t[0] === 'e') + if (!eTag?.[1]) { + throw new Error('Repost must have an e tag') + } + + // Find the target author (first 'p' tag) + const pTag = event.tags.find((t) => t[0] === 'p') + if (!pTag?.[1]) { + throw new Error('Repost must have a p tag') + } + + const targetEventId = EventId.fromHex(eTag[1]) + const targetAuthor = Pubkey.fromHex(pTag[1]) + + // Try to parse embedded event from content + let embeddedEvent: Event | null = null + if (event.content) { + try { + embeddedEvent = JSON.parse(event.content) as Event + } catch { + // Content is not valid JSON, that's fine + } + } + + return new Repost(event, targetEventId, targetAuthor, embeddedEvent) + } + + /** + * Try to create a Repost from an Event, returns null if invalid + */ + static tryFromEvent(event: Event | null | undefined): Repost | null { + if (!event) return null + try { + return Repost.fromEvent(event) + } catch { + return null + } + } + + /** + * The underlying Nostr event + */ + get event(): Event { + return this._event + } + + /** + * The repost's event ID + */ + get id(): EventId { + return EventId.fromHex(this._event.id) + } + + /** + * The author who reposted + */ + get author(): Pubkey { + return Pubkey.fromHex(this._event.pubkey) + } + + /** + * The event ID being reposted + */ + get targetEventId(): EventId { + return this._targetEventId + } + + /** + * The author of the original event + */ + get targetAuthor(): Pubkey { + return this._targetAuthor + } + + /** + * When the repost was created + */ + get createdAt(): Timestamp { + return Timestamp.fromUnix(this._event.created_at) + } + + /** + * Whether this is a standard repost (kind 6) + */ + get isStandardRepost(): boolean { + return this._event.kind === kinds.Repost + } + + /** + * Whether this is a generic repost (kind 16) + */ + get isGenericRepost(): boolean { + return this._event.kind === kinds.GenericRepost + } + + /** + * The embedded/quoted event (if included in content) + */ + get embeddedEvent(): Event | null { + return this._embeddedEvent + } + + /** + * Whether the repost includes the embedded event + */ + get hasEmbeddedEvent(): boolean { + return this._embeddedEvent !== null + } + + /** + * Get the kind of the reposted event (from k tag) + */ + get targetKind(): number | undefined { + const kTag = this._event.tags.find((t) => t[0] === 'k') + return kTag?.[1] ? parseInt(kTag[1], 10) : undefined + } +} diff --git a/src/domain/content/adapters.ts b/src/domain/content/adapters.ts new file mode 100644 index 00000000..be3b0463 --- /dev/null +++ b/src/domain/content/adapters.ts @@ -0,0 +1,175 @@ +/** + * Adapter functions for gradual migration from legacy code to Content domain objects. + */ + +import { Event, kinds } from 'nostr-tools' +import { Note } from './Note' +import { Reaction } from './Reaction' +import { Repost } from './Repost' + +// ============================================================================ +// Note Adapters +// ============================================================================ + +/** + * Convert a Nostr event to a Note domain object + */ +export const toNote = (event: Event): Note => { + return Note.fromEvent(event) +} + +/** + * Try to create a Note from an event, returns null if invalid + */ +export const tryToNote = (event: Event | null | undefined): Note | null => { + return Note.tryFromEvent(event) +} + +/** + * Check if an event is a short text note + */ +export const isNoteEvent = (event: Event): boolean => { + return event.kind === kinds.ShortTextNote +} + +/** + * Convert multiple events to Notes + * Filters out non-note events + */ +export const toNotes = (events: Event[]): Note[] => { + return events + .filter(isNoteEvent) + .map((e) => tryToNote(e)) + .filter((n): n is Note => n !== null) +} + +// ============================================================================ +// Reaction Adapters +// ============================================================================ + +/** + * Convert a Nostr event to a Reaction domain object + */ +export const toReaction = (event: Event): Reaction => { + return Reaction.fromEvent(event) +} + +/** + * Try to create a Reaction from an event, returns null if invalid + */ +export const tryToReaction = (event: Event | null | undefined): Reaction | null => { + return Reaction.tryFromEvent(event) +} + +/** + * Check if an event is a reaction + */ +export const isReactionEvent = (event: Event): boolean => { + return event.kind === kinds.Reaction +} + +/** + * Convert multiple events to Reactions + */ +export const toReactions = (events: Event[]): Reaction[] => { + return events + .filter(isReactionEvent) + .map((e) => tryToReaction(e)) + .filter((r): r is Reaction => r !== null) +} + +/** + * Group reactions by emoji + */ +export const groupReactionsByEmoji = ( + reactions: Reaction[] +): Map<string, Reaction[]> => { + const groups = new Map<string, Reaction[]>() + + for (const reaction of reactions) { + const emoji = reaction.emoji + const existing = groups.get(emoji) || [] + existing.push(reaction) + groups.set(emoji, existing) + } + + return groups +} + +/** + * Count likes for an event + */ +export const countLikes = (reactions: Reaction[]): number => { + return reactions.filter((r) => r.isLike).length +} + +// ============================================================================ +// Repost Adapters +// ============================================================================ + +/** + * Convert a Nostr event to a Repost domain object + */ +export const toRepost = (event: Event): Repost => { + return Repost.fromEvent(event) +} + +/** + * Try to create a Repost from an event, returns null if invalid + */ +export const tryToRepost = (event: Event | null | undefined): Repost | null => { + return Repost.tryFromEvent(event) +} + +/** + * Check if an event is a repost + */ +export const isRepostEvent = (event: Event): boolean => { + return event.kind === kinds.Repost || event.kind === kinds.GenericRepost +} + +/** + * Convert multiple events to Reposts + */ +export const toReposts = (events: Event[]): Repost[] => { + return events + .filter(isRepostEvent) + .map((e) => tryToRepost(e)) + .filter((r): r is Repost => r !== null) +} + +// ============================================================================ +// Content Type Detection +// ============================================================================ + +/** + * Determine the content type of an event + */ +export const getContentType = ( + event: Event +): 'note' | 'reaction' | 'repost' | 'other' => { + if (event.kind === kinds.ShortTextNote) return 'note' + if (event.kind === kinds.Reaction) return 'reaction' + if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) return 'repost' + return 'other' +} + +/** + * Parse any content event into its appropriate domain object + */ +export const parseContentEvent = ( + event: Event +): Note | Reaction | Repost | null => { + const type = getContentType(event) + + switch (type) { + case 'note': + return tryToNote(event) + case 'reaction': + return tryToReaction(event) + case 'repost': + return tryToRepost(event) + default: + return null + } +} diff --git a/src/domain/content/errors.ts b/src/domain/content/errors.ts new file mode 100644 index 00000000..6c1b8e2a --- /dev/null +++ b/src/domain/content/errors.ts @@ -0,0 +1,50 @@ +/** + * Domain errors for Content bounded context + */ + +import { DomainError } from '../shared' + +/** + * Thrown when content validation fails + */ +export class InvalidContentError extends DomainError { + constructor(reason: string) { + super(`Invalid content: ${reason}`) + } +} + +/** + * Thrown when a note operation fails + */ +export class NoteOperationError extends DomainError { + constructor(operation: string, reason?: string) { + super(`Note operation failed: ${operation}${reason ? ` - ${reason}` : ''}`) + } +} + +/** + * Thrown when attempting to react to own content + */ +export class CannotReactToOwnContentError extends DomainError { + constructor() { + super('Cannot react to your own content') + } +} + +/** + * Thrown when a note is not found + */ +export class NoteNotFoundError extends DomainError { + constructor(id: string) { + super(`Note not found: ${id}`) + } +} + +/** + * Thrown when content exceeds size limits + */ +export class ContentTooLargeError extends DomainError { + constructor(maxSize: number) { + super(`Content exceeds maximum size of ${maxSize} characters`) + } +} diff --git a/src/domain/content/index.ts b/src/domain/content/index.ts new file mode 100644 index 00000000..42713209 --- /dev/null +++ b/src/domain/content/index.ts @@ -0,0 +1,47 @@ +/** + * Content Bounded Context + * + * Handles notes, reactions, reposts, and other content types. + */ + +// Entities +export { Note } from './Note' +export type { NoteType, NoteMention, NoteReference } from './Note' + +export { Reaction } from './Reaction' +export type { ReactionType, CustomEmoji } from './Reaction' + +export { Repost } from './Repost' + +// Errors +export { + InvalidContentError, + NoteOperationError, + CannotReactToOwnContentError, + NoteNotFoundError, + ContentTooLargeError +} from './errors' + +// Adapters for migration +export { + // Note adapters + toNote, + tryToNote, + isNoteEvent, + toNotes, + // Reaction adapters + toReaction, + tryToReaction, + isReactionEvent, + toReactions, + groupReactionsByEmoji, + countLikes, + // Repost adapters + toRepost, + tryToRepost, + isRepostEvent, + toReposts, + // Content type detection + getContentType, + parseContentEvent +} from './adapters' diff --git a/src/domain/identity/Account.ts b/src/domain/identity/Account.ts new file mode 100644 index 00000000..0192f935 --- /dev/null +++ b/src/domain/identity/Account.ts @@ -0,0 +1,190 @@ +import { Pubkey } from '../shared' +import { SignerType, SignerTypeValue } from './SignerType' + +/** + * Credentials for different signer types + */ +export type AccountCredentials = { + ncryptsec?: string + nsec?: string +} + +/** + * Account Entity + * + * Represents a user account in the application. + * An account is identified by a public key and a signer type. + * + * Note: The same pubkey with different signer types are considered + * different accounts (e.g., you might have nsec and nip-07 for the same key). + */ +export class Account { + private constructor( + private readonly _pubkey: Pubkey, + private readonly _signerType: SignerType, + private readonly _credentials: AccountCredentials + ) {} + + /** + * Create a new account + */ + static create( + pubkey: Pubkey, + signerType: SignerType, + credentials: AccountCredentials = {} + ): Account { + return new Account(pubkey, signerType, { ...credentials }) + } + + /** + * Create an account from raw values + */ + static fromRaw( + pubkeyHex: string, + signerTypeValue: SignerTypeValue, + credentials: AccountCredentials = {} + ): Account { + const pubkey = Pubkey.fromHex(pubkeyHex) + const signerType = SignerType.fromString(signerTypeValue) + return new Account(pubkey, signerType, { ...credentials }) + } + + /** + * Try to create an account from raw values + */ + static tryFromRaw( + pubkeyHex: string, + signerTypeValue: string, + credentials: AccountCredentials = {} + ): Account | null { + try { + const pubkey = Pubkey.tryFromString(pubkeyHex) + if (!pubkey) return null + + const signerType = SignerType.tryFromString(signerTypeValue) + if (!signerType) return null + + return new Account(pubkey, signerType, { ...credentials }) + } catch { + return null + } + } + + /** + * Create from legacy TAccount format + */ + static fromLegacy(legacy: { + pubkey: string + signerType: SignerTypeValue + ncryptsec?: string + nsec?: string + npub?: string + }): Account | null { + const pubkey = Pubkey.tryFromString(legacy.pubkey) + if (!pubkey) return null + + const signerType = SignerType.tryFromString(legacy.signerType) + if (!signerType) return null + + return new Account(pubkey, signerType, { + ncryptsec: legacy.ncryptsec, + nsec: legacy.nsec + }) + } + + /** + * The account's public key + */ + get pubkey(): Pubkey { + return this._pubkey + } + + /** + * The signer type used by this account + */ + get signerType(): SignerType { + return this._signerType + } + + /** + * Whether this account can sign events + */ + get canSign(): boolean { + return this._signerType.canSign + } + + /** + * Whether this is a view-only account + */ + get isViewOnly(): boolean { + return this._signerType.isViewOnly + } + + /** + * Get the ncryptsec credential (if available) + */ + get ncryptsec(): string | undefined { + return this._credentials.ncryptsec + } + + /** + * Create a unique identifier for this account + * Combination of pubkey and signer type + */ + get id(): string { + return `${this._pubkey.hex}:${this._signerType.value}` + } + + /** + * Check if this is the same account (same pubkey and signer type) + */ + equals(other: Account): boolean { + return this._pubkey.equals(other._pubkey) && this._signerType.equals(other._signerType) + } + + /** + * Check if this account has the same pubkey as another + */ + hasSamePubkey(other: Account): boolean { + return this._pubkey.equals(other._pubkey) + } + + /** + * Convert to legacy TAccount format + */ + toLegacy(): { + pubkey: string + signerType: SignerTypeValue + ncryptsec?: string + nsec?: string + npub?: string + } { + return { + pubkey: this._pubkey.hex, + signerType: this._signerType.value, + ncryptsec: this._credentials.ncryptsec, + nsec: this._credentials.nsec, + npub: this._signerType.isViewOnly ? this._pubkey.npub : undefined + } + } + + /** + * Create an account pointer (pubkey + signer type, no credentials) + */ + toPointer(): { pubkey: string; signerType: SignerTypeValue } { + return { + pubkey: this._pubkey.hex, + signerType: this._signerType.value + } + } + + /** + * For JSON serialization (excludes sensitive credentials) + */ + toJSON(): { pubkey: string; signerType: string } { + return { + pubkey: this._pubkey.hex, + signerType: this._signerType.value + } + } +} diff --git a/src/domain/identity/SignerType.ts b/src/domain/identity/SignerType.ts new file mode 100644 index 00000000..55e283ab --- /dev/null +++ b/src/domain/identity/SignerType.ts @@ -0,0 +1,138 @@ +/** + * SignerType Value Object + * + * Represents the type of signer/authentication method used for an account. + */ + +const VALID_SIGNER_TYPES = ['nsec', 'nip-07', 'browser-nsec', 'ncryptsec', 'npub'] as const + +export type SignerTypeValue = (typeof VALID_SIGNER_TYPES)[number] + +/** + * SignerType Value Object + * + * Represents how a user authenticates and signs events. + * - nsec: Raw private key (stored securely) + * - nip-07: Browser extension (nos2x, Alby, etc.) + * - browser-nsec: Private key in browser storage (less secure) + * - ncryptsec: Encrypted private key (NIP-49) + * - npub: View-only mode (no signing capability) + */ +export class SignerType { + private constructor(private readonly _value: SignerTypeValue) {} + + static readonly NSEC = new SignerType('nsec') + static readonly NIP07 = new SignerType('nip-07') + static readonly BROWSER_NSEC = new SignerType('browser-nsec') + static readonly NCRYPTSEC = new SignerType('ncryptsec') + static readonly NPUB = new SignerType('npub') + + /** + * Create a SignerType from a string value + */ + static fromString(value: string): SignerType { + if (!SignerType.isValid(value)) { + throw new Error(`Invalid signer type: ${value}`) + } + return new SignerType(value as SignerTypeValue) + } + + /** + * Try to create a SignerType from a string, returns null if invalid + */ + static tryFromString(value: string): SignerType | null { + try { + return SignerType.fromString(value) + } catch { + return null + } + } + + /** + * Check if a string is a valid signer type + */ + static isValid(value: string): value is SignerTypeValue { + return VALID_SIGNER_TYPES.includes(value as SignerTypeValue) + } + + /** + * Get all valid signer types + */ + static all(): SignerType[] { + return VALID_SIGNER_TYPES.map((v) => new SignerType(v)) + } + + /** + * The raw string value + */ + get value(): SignerTypeValue { + return this._value + } + + /** + * Whether this signer can sign events + */ + get canSign(): boolean { + return this._value !== 'npub' + } + + /** + * Whether this signer stores keys locally + */ + get storesKeysLocally(): boolean { + return ['nsec', 'browser-nsec', 'ncryptsec'].includes(this._value) + } + + /** + * Whether this signer uses a remote/external service + */ + get isRemote(): boolean { + return this._value === 'nip-07' + } + + /** + * Whether this is view-only mode + */ + get isViewOnly(): boolean { + return this._value === 'npub' + } + + /** + * Human-readable display name + */ + get displayName(): string { + switch (this._value) { + case 'nsec': + return 'Private Key' + case 'nip-07': + return 'Browser Extension' + case 'browser-nsec': + return 'Browser Key' + case 'ncryptsec': + return 'Encrypted Key' + case 'npub': + return 'View Only' + } + } + + /** + * Check equality with another SignerType + */ + equals(other: SignerType): boolean { + return this._value === other._value + } + + /** + * Returns the string value + */ + toString(): string { + return this._value + } + + /** + * For JSON serialization + */ + toJSON(): string { + return this._value + } +} diff --git a/src/domain/identity/adapters.ts b/src/domain/identity/adapters.ts new file mode 100644 index 00000000..094d56e6 --- /dev/null +++ b/src/domain/identity/adapters.ts @@ -0,0 +1,171 @@ +/** + * Adapter functions for gradual migration from legacy code to Identity domain objects. + */ + +import { Account } from './Account' +import { SignerType, SignerTypeValue } from './SignerType' + +/** + * Legacy TAccount type for reference + */ +type LegacyAccount = { + pubkey: string + signerType: SignerTypeValue + ncryptsec?: string + nsec?: string + npub?: string +} + +/** + * Legacy TAccountPointer type for reference + */ +type LegacyAccountPointer = { + pubkey: string + signerType: SignerTypeValue +} + +// ============================================================================ +// Account Adapters +// ============================================================================ + +/** + * Convert a legacy TAccount to an Account domain object + */ +export const toAccount = (legacy: LegacyAccount): Account | null => { + return Account.fromLegacy(legacy) +} + +/** + * Convert an Account domain object to legacy TAccount format + */ +export const fromAccount = (account: Account): LegacyAccount => { + return account.toLegacy() +} + +/** + * Convert multiple legacy accounts to domain objects + */ +export const toAccounts = (legacyAccounts: LegacyAccount[]): Account[] => { + return legacyAccounts.map((a) => toAccount(a)).filter((a): a is Account => a !== null) +} + +/** + * Convert multiple domain accounts to legacy format + */ +export const fromAccounts = (accounts: Account[]): LegacyAccount[] => { + return accounts.map((a) => fromAccount(a)) +} + +/** + * Check if two account pointers are the same + */ +export const isSameAccount = ( + a: LegacyAccountPointer | null, + b: LegacyAccountPointer | null +): boolean => { + if (!a || !b) return false + return a.pubkey === b.pubkey && a.signerType === b.signerType +} + +/** + * Check if two accounts have the same pubkey + */ +export const isSamePubkey = ( + a: LegacyAccountPointer | null, + b: LegacyAccountPointer | null +): boolean => { + if (!a || !b) return false + return a.pubkey === b.pubkey +} + +// ============================================================================ +// SignerType Adapters +// ============================================================================ + +/** + * Convert a string to a SignerType domain object + */ +export const toSignerType = (value: string): SignerType | null => { + return SignerType.tryFromString(value) +} + +/** + * Convert a SignerType to its string value + */ +export const fromSignerType = (signerType: SignerType): SignerTypeValue => { + return signerType.value +} + +/** + * Check if a signer type can sign events + */ +export const canSign = (signerType: string): boolean => { + const type = SignerType.tryFromString(signerType) + return type ? type.canSign : false +} + +/** + * Check if a signer type is view-only + */ +export const isViewOnly = (signerType: string): boolean => { + const type = SignerType.tryFromString(signerType) + return type ? type.isViewOnly : false +} + +/** + * Get the display name for a signer type + */ +export const getSignerTypeDisplayName = (signerType: string): string => { + const type = SignerType.tryFromString(signerType) + return type ? type.displayName : 'Unknown' +} + +// ============================================================================ +// Account List Helpers +// ============================================================================ + +/** + * Find an account in a list by pubkey and signer type + */ +export const findAccount = ( + accounts: Account[], + pubkey: string, + signerType: string +): Account | undefined => { + return accounts.find( + (a) => a.pubkey.hex === pubkey && a.signerType.value === signerType + ) +} + +/** + * Find all accounts with a specific pubkey + */ +export const findAccountsByPubkey = (accounts: Account[], pubkey: string): Account[] => { + return accounts.filter((a) => a.pubkey.hex === pubkey) +} + +/** + * Remove an account from a list + */ +export const removeAccount = ( + accounts: Account[], + pubkey: string, + signerType: string +): Account[] => { + return accounts.filter( + (a) => !(a.pubkey.hex === pubkey && a.signerType.value === signerType) + ) +} + +/** + * Add or update an account in a list + */ +export const upsertAccount = (accounts: Account[], account: Account): Account[] => { + const existing = accounts.findIndex((a) => a.equals(account)) + if (existing >= 0) { + const result = [...accounts] + result[existing] = account + return result + } + return [...accounts, account] +} diff --git a/src/domain/identity/errors.ts b/src/domain/identity/errors.ts new file mode 100644 index 00000000..8232a8f0 --- /dev/null +++ b/src/domain/identity/errors.ts @@ -0,0 +1,50 @@ +/** + * Domain errors for Identity bounded context + */ + +import { DomainError } from '../shared' + +/** + * Thrown when login credentials are invalid + */ +export class InvalidCredentialsError extends DomainError { + constructor(reason?: string) { + super(`Invalid credentials${reason ? `: ${reason}` : ''}`) + } +} + +/** + * Thrown when an account operation fails + */ +export class AccountOperationError extends DomainError { + constructor(operation: string, reason?: string) { + super(`Account operation failed: ${operation}${reason ? ` - ${reason}` : ''}`) + } +} + +/** + * Thrown when attempting to sign without authentication + */ +export class NotLoggedInError extends DomainError { + constructor() { + super('Not logged in - authentication required') + } +} + +/** + * Thrown when a signer operation fails + */ +export class SignerError extends DomainError { + constructor(operation: string, reason?: string) { + super(`Signer error: ${operation}${reason ? ` - ${reason}` : ''}`) + } +} + +/** + * Thrown when an account is not found + */ +export class AccountNotFoundError extends DomainError { + constructor(identifier?: string) { + super(`Account not found${identifier ? `: ${identifier}` : ''}`) + } +} diff --git a/src/domain/identity/index.ts b/src/domain/identity/index.ts new file mode 100644 index 00000000..a735df58 --- /dev/null +++ b/src/domain/identity/index.ts @@ -0,0 +1,44 @@ +/** + * Identity Bounded Context + * + * Handles accounts, authentication, and signing. + */ + +// Entities +export { Account } from './Account' +export type { AccountCredentials } from './Account' + +// Value Objects +export { SignerType } from './SignerType' +export type { SignerTypeValue } from './SignerType' + +// Errors +export { + InvalidCredentialsError, + AccountOperationError, + NotLoggedInError, + SignerError, + AccountNotFoundError +} from './errors' + +// Adapters for migration +export { + // Account adapters + toAccount, + fromAccount, + toAccounts, + fromAccounts, + isSameAccount, + isSamePubkey, + // SignerType adapters + toSignerType, + fromSignerType, + canSign, + isViewOnly, + getSignerTypeDisplayName, + // Account list helpers + findAccount, + findAccountsByPubkey, + removeAccount, + upsertAccount +} from './adapters' diff --git a/src/domain/index.ts b/src/domain/index.ts new file mode 100644 index 00000000..6ac3f1a8 --- /dev/null +++ b/src/domain/index.ts @@ -0,0 +1,21 @@ +/** + * Domain Layer + * + * Core domain logic with no external dependencies. + * Organized into bounded contexts. + */ + +// Shared Kernel - Common value objects and utilities +export * from './shared' + +// Social Bounded Context - Following, muting, social graph +export * from './social' + +// Relay Bounded Context - Relay management and preferences +export * from './relay' + +// Identity Bounded Context - Accounts and authentication +export * from './identity' + +// Content Bounded Context - Notes, reactions, reposts +export * from './content' diff --git a/src/domain/relay/FavoriteRelays.ts b/src/domain/relay/FavoriteRelays.ts new file mode 100644 index 00000000..271e77e5 --- /dev/null +++ b/src/domain/relay/FavoriteRelays.ts @@ -0,0 +1,340 @@ +import { Event, kinds } from 'nostr-tools' +import { Pubkey, RelayUrl, Timestamp } from '../shared' +import { RelaySet } from './RelaySet' + +/** + * Result of a favorite relays modification + */ +export type FavoriteRelaysChange = + | { type: 'relay_added'; relay: RelayUrl } + | { type: 'relay_removed'; relay: RelayUrl } + | { type: 'set_added'; set: RelaySet } + | { type: 'set_removed'; setId: string } + | { type: 'no_change' } + +/** + * FavoriteRelays Aggregate + * + * Represents a user's favorite relays collection (kind 10012 in Nostr). + * Combines individual relay URLs and references to relay sets. + * + * This is the user's curated list of relays they want quick access to, + * separate from their mailbox relays (kind 10002). + */ +export class FavoriteRelays { + private readonly _relays: Map<string, RelayUrl> + private readonly _sets: Map<string, RelaySet> + private readonly _setOrder: string[] + + private constructor( + private readonly _owner: Pubkey, + relays: RelayUrl[], + sets: RelaySet[] + ) { + this._relays = new Map() + this._sets = new Map() + this._setOrder = [] + + for (const relay of relays) { + this._relays.set(relay.value, relay) + } + for (const set of sets) { + this._sets.set(set.id, set) + this._setOrder.push(set.id) + } + } + + /** + * Create an empty FavoriteRelays for a user + */ + static empty(owner: Pubkey): FavoriteRelays { + return new FavoriteRelays(owner, [], []) + } + + /** + * Create FavoriteRelays from URLs only + */ + static fromUrls(owner: Pubkey, urls: string[]): FavoriteRelays { + const relays: RelayUrl[] = [] + for (const url of urls) { + const relay = RelayUrl.tryCreate(url) + if (relay && !relays.some((r) => r.value === relay.value)) { + relays.push(relay) + } + } + return new FavoriteRelays(owner, relays, []) + } + + /** + * Reconstruct FavoriteRelays from a Nostr kind 10012 event + * + * @param event The favorite relays event + * @param relaySets The relay set events referenced by 'a' tags + */ + static fromEvent(event: Event, relaySets: RelaySet[] = []): FavoriteRelays { + const owner = Pubkey.fromHex(event.pubkey) + const relays: RelayUrl[] = [] + const setIds: string[] = [] + + for (const tag of event.tags) { + if (tag[0] === 'relay' && tag[1]) { + const relay = RelayUrl.tryCreate(tag[1]) + if (relay && !relays.some((r) => r.value === relay.value)) { + relays.push(relay) + } + } else if (tag[0] === 'a' && tag[1]) { + const [kind, , setId] = tag[1].split(':') + if (kind === kinds.Relaysets.toString() && setId && !setIds.includes(setId)) { + setIds.push(setId) + } + } + } + + // Match relay sets to their IDs in order + const orderedSets: RelaySet[] = [] + for (const id of setIds) { + const set = relaySets.find((s) => s.id === id) + if (set) { + orderedSets.push(set) + } + } + + return new FavoriteRelays(owner, relays, orderedSets) + } + + /** + * The owner of this favorite relays list + */ + get owner(): Pubkey { + return this._owner + } + + /** + * Number of individual favorite relays + */ + get relayCount(): number { + return this._relays.size + } + + /** + * Number of relay sets + */ + get setCount(): number { + return this._sets.size + } + + /** + * Get all individual favorite relays + */ + getRelays(): RelayUrl[] { + return Array.from(this._relays.values()) + } + + /** + * Get all relay URLs as strings + */ + getRelayUrls(): string[] { + return Array.from(this._relays.keys()) + } + + /** + * Get all relay sets in order + */ + getSets(): RelaySet[] { + return this._setOrder.map((id) => this._sets.get(id)!).filter(Boolean) + } + + /** + * Get a relay set by ID + */ + getSet(id: string): RelaySet | undefined { + return this._sets.get(id) + } + + /** + * Get all unique relays (from both individual relays and sets) + */ + getAllUniqueRelays(): RelayUrl[] { + const all = new Map<string, RelayUrl>() + + for (const relay of this._relays.values()) { + all.set(relay.value, relay) + } + + for (const set of this._sets.values()) { + for (const relay of set.getRelays()) { + all.set(relay.value, relay) + } + } + + return Array.from(all.values()) + } + + /** + * Check if a relay is in the favorites + */ + hasRelay(relay: RelayUrl): boolean { + return this._relays.has(relay.value) + } + + /** + * Check if a relay set is in the favorites + */ + hasSet(id: string): boolean { + return this._sets.has(id) + } + + /** + * Add a relay to favorites + * + * @returns FavoriteRelaysChange indicating what changed + */ + addRelay(relay: RelayUrl): FavoriteRelaysChange { + if (this._relays.has(relay.value)) { + return { type: 'no_change' } + } + + this._relays.set(relay.value, relay) + return { type: 'relay_added', relay } + } + + /** + * Add multiple relays to favorites + */ + addRelays(relays: RelayUrl[]): FavoriteRelaysChange[] { + return relays.map((r) => this.addRelay(r)) + } + + /** + * Add a relay by URL string + */ + addRelayUrl(url: string): FavoriteRelaysChange | null { + const relay = RelayUrl.tryCreate(url) + if (!relay) return null + return this.addRelay(relay) + } + + /** + * Remove a relay from favorites + * + * @returns FavoriteRelaysChange indicating what changed + */ + removeRelay(relay: RelayUrl): FavoriteRelaysChange { + if (!this._relays.has(relay.value)) { + return { type: 'no_change' } + } + + this._relays.delete(relay.value) + return { type: 'relay_removed', relay } + } + + /** + * Remove multiple relays from favorites + */ + removeRelays(relays: RelayUrl[]): FavoriteRelaysChange[] { + return relays.map((r) => this.removeRelay(r)) + } + + /** + * Add a relay set to favorites + * + * @returns FavoriteRelaysChange indicating what changed + */ + addSet(set: RelaySet): FavoriteRelaysChange { + if (this._sets.has(set.id)) { + return { type: 'no_change' } + } + + this._sets.set(set.id, set) + this._setOrder.push(set.id) + return { type: 'set_added', set } + } + + /** + * Remove a relay set from favorites + * + * @returns FavoriteRelaysChange indicating what changed + */ + removeSet(id: string): FavoriteRelaysChange { + if (!this._sets.has(id)) { + return { type: 'no_change' } + } + + this._sets.delete(id) + const index = this._setOrder.indexOf(id) + if (index !== -1) { + this._setOrder.splice(index, 1) + } + return { type: 'set_removed', setId: id } + } + + /** + * Update a relay set + */ + updateSet(set: RelaySet): boolean { + if (!this._sets.has(set.id)) { + return false + } + this._sets.set(set.id, set) + return true + } + + /** + * Reorder the favorite relays + */ + reorderRelays(newOrder: RelayUrl[]): void { + this._relays.clear() + for (const relay of newOrder) { + this._relays.set(relay.value, relay) + } + } + + /** + * Reorder the relay sets + */ + reorderSets(newOrder: RelaySet[]): void { + this._setOrder.length = 0 + for (const set of newOrder) { + if (this._sets.has(set.id)) { + this._setOrder.push(set.id) + } + } + } + + /** + * Convert to Nostr event tags format + */ + toTags(pubkey: string): string[][] { + const tags: string[][] = [] + + for (const relay of this._relays.values()) { + tags.push(['relay', relay.value]) + } + + for (const id of this._setOrder) { + const set = this._sets.get(id) + if (set) { + tags.push(['a', `${kinds.Relaysets}:${pubkey}:${id}`]) + } + } + + return tags + } + + /** + * Convert to a draft event for publishing + */ + toDraftEvent(pubkey: string): { + kind: number + content: string + created_at: number + tags: string[][] + } { + return { + kind: 10012, // ExtendedKind.FAVORITE_RELAYS + content: '', + created_at: Timestamp.now().unix, + tags: this.toTags(pubkey) + } + } +} diff --git a/src/domain/relay/RelayList.ts b/src/domain/relay/RelayList.ts new file mode 100644 index 00000000..55410d83 --- /dev/null +++ b/src/domain/relay/RelayList.ts @@ -0,0 +1,295 @@ +import { Event, kinds } from 'nostr-tools' +import { Pubkey, RelayUrl, Timestamp } from '../shared' + +/** + * The scope of a relay in a relay list (read, write, or both) + */ +export type RelayScope = 'read' | 'write' | 'both' + +/** + * A relay entry with its scope + */ +export type RelayEntry = { + relay: RelayUrl + scope: RelayScope +} + +/** + * Result of a relay list modification + */ +export type RelayListChange = + | { type: 'added'; relay: RelayUrl; scope: RelayScope } + | { type: 'removed'; relay: RelayUrl } + | { type: 'scope_changed'; relay: RelayUrl; from: RelayScope; to: RelayScope } + | { type: 'no_change' } + +/** + * RelayList Aggregate + * + * Represents a user's mailbox relay preferences (kind 10002 in Nostr, NIP-65). + * Defines which relays the user reads from and writes to. + * + * Invariants: + * - No duplicate relay URLs + * - All URLs must be valid WebSocket URLs + * - At least one read and one write relay is recommended (but not enforced) + */ +export class RelayList { + private readonly _relays: Map<string, RelayEntry> + + private constructor( + private readonly _owner: Pubkey, + entries: RelayEntry[] + ) { + this._relays = new Map() + for (const entry of entries) { + this._relays.set(entry.relay.value, entry) + } + } + + /** + * Create an empty RelayList for a user + */ + static empty(owner: Pubkey): RelayList { + return new RelayList(owner, []) + } + + /** + * Create a RelayList with initial relays (all set to 'both') + */ + static fromUrls(owner: Pubkey, urls: string[]): RelayList { + const entries: RelayEntry[] = [] + for (const url of urls) { + const relay = RelayUrl.tryCreate(url) + if (relay) { + entries.push({ relay, scope: 'both' }) + } + } + return new RelayList(owner, entries) + } + + /** + * Reconstruct a RelayList from a Nostr kind 10002 event + * + * @param event The relay list event + * @param filterOutOnion Whether to filter out .onion addresses + */ + static fromEvent(event: Event, filterOutOnion = false): RelayList { + if (event.kind !== kinds.RelayList) { + throw new Error(`Expected kind ${kinds.RelayList}, got ${event.kind}`) + } + + const owner = Pubkey.fromHex(event.pubkey) + const entries: RelayEntry[] = [] + + for (const tag of event.tags) { + if (tag[0] === 'r' && tag[1]) { + const relay = RelayUrl.tryCreate(tag[1]) + if (!relay) continue + if (filterOutOnion && relay.isOnion) continue + + let scope: RelayScope = 'both' + if (tag[2] === 'read') { + scope = 'read' + } else if (tag[2] === 'write') { + scope = 'write' + } + + entries.push({ relay, scope }) + } + } + + return new RelayList(owner, entries) + } + + /** + * The owner of this relay list + */ + get owner(): Pubkey { + return this._owner + } + + /** + * Total number of relays + */ + get count(): number { + return this._relays.size + } + + /** + * Get all relay entries + */ + getEntries(): RelayEntry[] { + return Array.from(this._relays.values()) + } + + /** + * Get all relays (regardless of scope) + */ + getAllRelays(): RelayUrl[] { + return Array.from(this._relays.values()).map((e) => e.relay) + } + + /** + * Get all relay URLs as strings + */ + getAllUrls(): string[] { + return Array.from(this._relays.keys()) + } + + /** + * Get read relays (scope is 'read' or 'both') + */ + getReadRelays(): RelayUrl[] { + return Array.from(this._relays.values()) + .filter((e) => e.scope === 'read' || e.scope === 'both') + .map((e) => e.relay) + } + + /** + * Get read relay URLs as strings + */ + getReadUrls(): string[] { + return this.getReadRelays().map((r) => r.value) + } + + /** + * Get write relays (scope is 'write' or 'both') + */ + getWriteRelays(): RelayUrl[] { + return Array.from(this._relays.values()) + .filter((e) => e.scope === 'write' || e.scope === 'both') + .map((e) => e.relay) + } + + /** + * Get write relay URLs as strings + */ + getWriteUrls(): string[] { + return this.getWriteRelays().map((r) => r.value) + } + + /** + * Check if a relay is in this list + */ + hasRelay(relay: RelayUrl): boolean { + return this._relays.has(relay.value) + } + + /** + * Get the scope for a relay + */ + getScope(relay: RelayUrl): RelayScope | null { + const entry = this._relays.get(relay.value) + return entry ? entry.scope : null + } + + /** + * Add or update a relay with a specific scope + * + * @returns RelayListChange indicating what changed + */ + setRelay(relay: RelayUrl, scope: RelayScope): RelayListChange { + const existing = this._relays.get(relay.value) + + if (existing) { + if (existing.scope === scope) { + return { type: 'no_change' } + } + const oldScope = existing.scope + this._relays.set(relay.value, { relay, scope }) + return { type: 'scope_changed', relay, from: oldScope, to: scope } + } + + this._relays.set(relay.value, { relay, scope }) + return { type: 'added', relay, scope } + } + + /** + * Add a relay by URL string + * + * @returns RelayListChange or null if URL is invalid + */ + setRelayUrl(url: string, scope: RelayScope): RelayListChange | null { + const relay = RelayUrl.tryCreate(url) + if (!relay) return null + return this.setRelay(relay, scope) + } + + /** + * Remove a relay from this list + * + * @returns RelayListChange indicating what changed + */ + removeRelay(relay: RelayUrl): RelayListChange { + if (!this._relays.has(relay.value)) { + return { type: 'no_change' } + } + + this._relays.delete(relay.value) + return { type: 'removed', relay } + } + + /** + * Remove a relay by URL string + * + * @returns RelayListChange or null if URL is invalid + */ + removeRelayUrl(url: string): RelayListChange | null { + const relay = RelayUrl.tryCreate(url) + if (!relay) return null + return this.removeRelay(relay) + } + + /** + * Replace all relays with a new list + */ + setEntries(entries: RelayEntry[]): void { + this._relays.clear() + for (const entry of entries) { + this._relays.set(entry.relay.value, entry) + } + } + + /** + * Convert to Nostr event tags format + */ + toTags(): string[][] { + return Array.from(this._relays.values()).map((entry) => { + if (entry.scope === 'both') { + return ['r', entry.relay.value] + } + return ['r', entry.relay.value, entry.scope] + }) + } + + /** + * Convert to a draft event for publishing + */ + toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } { + return { + kind: kinds.RelayList, + content: '', + created_at: Timestamp.now().unix, + tags: this.toTags() + } + } + + /** + * Convert to the legacy TRelayList format + */ + toLegacyFormat(): { + read: string[] + write: string[] + originalRelays: Array<{ url: string; scope: RelayScope }> + } { + return { + read: this.getReadUrls(), + write: this.getWriteUrls(), + originalRelays: Array.from(this._relays.values()).map((e) => ({ + url: e.relay.value, + scope: e.scope + })) + } + } +} diff --git a/src/domain/relay/RelaySet.ts b/src/domain/relay/RelaySet.ts new file mode 100644 index 00000000..dfc14aba --- /dev/null +++ b/src/domain/relay/RelaySet.ts @@ -0,0 +1,245 @@ +import { Event, kinds } from 'nostr-tools' +import { RelayUrl, Timestamp } from '../shared' + +/** + * Result of a relay set modification + */ +export type RelaySetChange = + | { type: 'added'; relay: RelayUrl } + | { type: 'removed'; relay: RelayUrl } + | { type: 'no_change' } + +/** + * RelaySet Aggregate + * + * Represents a named collection of relays (kind 30002 in Nostr). + * Used for organizing relays into groups like "fast relays", "paid relays", etc. + * + * Invariants: + * - Name is required and non-empty + * - No duplicate relay URLs + * - All URLs must be valid WebSocket URLs + */ +export class RelaySet { + private readonly _relays: Map<string, RelayUrl> + + private constructor( + private readonly _id: string, + private _name: string, + relays: RelayUrl[] + ) { + this._relays = new Map() + for (const relay of relays) { + this._relays.set(relay.value, relay) + } + } + + /** + * Create a new empty RelaySet with a generated ID + */ + static create(name: string, id?: string): RelaySet { + const setId = id || crypto.randomUUID().replace(/-/g, '').slice(0, 12) + return new RelaySet(setId, name.trim() || 'Unnamed Set', []) + } + + /** + * Create a RelaySet with initial relays + */ + static createWithRelays(name: string, relayUrls: string[], id?: string): RelaySet { + const set = RelaySet.create(name, id) + for (const url of relayUrls) { + const relay = RelayUrl.tryCreate(url) + if (relay) { + set._relays.set(relay.value, relay) + } + } + return set + } + + /** + * Reconstruct a RelaySet from a Nostr kind 30002 event + */ + static fromEvent(event: Event): RelaySet { + if (event.kind !== kinds.Relaysets) { + throw new Error(`Expected kind ${kinds.Relaysets}, got ${event.kind}`) + } + + let id = '' + let name = '' + const relays: RelayUrl[] = [] + + for (const tag of event.tags) { + if (tag[0] === 'd' && tag[1]) { + id = tag[1] + } else if (tag[0] === 'title' && tag[1]) { + name = tag[1] + } else if (tag[0] === 'relay' && tag[1]) { + const relay = RelayUrl.tryCreate(tag[1]) + if (relay) { + relays.push(relay) + } + } + } + + return new RelaySet(id || 'unknown', name || 'Unnamed Set', relays) + } + + /** + * The unique identifier for this relay set + */ + get id(): string { + return this._id + } + + /** + * The display name of this relay set + */ + get name(): string { + return this._name + } + + /** + * Number of relays in this set + */ + get count(): number { + return this._relays.size + } + + /** + * Check if the set is empty + */ + get isEmpty(): boolean { + return this._relays.size === 0 + } + + /** + * Get all relays in this set + */ + getRelays(): RelayUrl[] { + return Array.from(this._relays.values()) + } + + /** + * Get all relay URLs as strings + */ + getRelayUrls(): string[] { + return Array.from(this._relays.keys()) + } + + /** + * Check if a relay is in this set + */ + hasRelay(relay: RelayUrl): boolean { + return this._relays.has(relay.value) + } + + /** + * Check if a relay URL string is in this set + */ + hasRelayUrl(url: string): boolean { + const relay = RelayUrl.tryCreate(url) + return relay ? this._relays.has(relay.value) : false + } + + /** + * Rename this relay set + */ + rename(newName: string): void { + this._name = newName.trim() || 'Unnamed Set' + } + + /** + * Add a relay to this set + * + * @returns RelaySetChange indicating what changed + */ + addRelay(relay: RelayUrl): RelaySetChange { + if (this._relays.has(relay.value)) { + return { type: 'no_change' } + } + + this._relays.set(relay.value, relay) + return { type: 'added', relay } + } + + /** + * Add a relay by URL string + * + * @returns RelaySetChange or null if URL is invalid + */ + addRelayUrl(url: string): RelaySetChange | null { + const relay = RelayUrl.tryCreate(url) + if (!relay) return null + return this.addRelay(relay) + } + + /** + * Remove a relay from this set + * + * @returns RelaySetChange indicating what changed + */ + removeRelay(relay: RelayUrl): RelaySetChange { + if (!this._relays.has(relay.value)) { + return { type: 'no_change' } + } + + this._relays.delete(relay.value) + return { type: 'removed', relay } + } + + /** + * Remove a relay by URL string + * + * @returns RelaySetChange or null if URL is invalid + */ + removeRelayUrl(url: string): RelaySetChange | null { + const relay = RelayUrl.tryCreate(url) + if (!relay) return null + return this.removeRelay(relay) + } + + /** + * Replace all relays with a new list + */ + setRelays(relays: RelayUrl[]): void { + this._relays.clear() + for (const relay of relays) { + this._relays.set(relay.value, relay) + } + } + + /** + * Convert to the 'a' tag reference format + */ + toATag(pubkey: string): string[] { + return ['a', `${kinds.Relaysets}:${pubkey}:${this._id}`] + } + + /** + * Convert to Nostr event tags format + */ + toTags(): string[][] { + const tags: string[][] = [ + ['d', this._id], + ['title', this._name] + ] + + for (const relay of this._relays.values()) { + tags.push(['relay', relay.value]) + } + + return tags + } + + /** + * Convert to a draft event for publishing + */ + toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } { + return { + kind: kinds.Relaysets, + content: '', + created_at: Timestamp.now().unix, + tags: this.toTags() + } + } +} diff --git a/src/domain/relay/adapters.ts b/src/domain/relay/adapters.ts new file mode 100644 index 00000000..c82564b7 --- /dev/null +++ b/src/domain/relay/adapters.ts @@ -0,0 +1,208 @@ +/** + * Adapter functions for gradual migration from legacy code to Relay domain objects. + */ + +import { Event } from 'nostr-tools' +import { RelayUrl, tryToRelayUrl } from '../shared' +import { RelayList, RelayScope } from './RelayList' +import { RelaySet } from './RelaySet' +import { FavoriteRelays } from './FavoriteRelays' + +// ============================================================================ +// RelayList Adapters +// ============================================================================ + +/** + * Convert a Nostr event to a RelayList domain object + */ +export const toRelayList = (event: Event, filterOutOnion = false): RelayList => { + return RelayList.fromEvent(event, filterOutOnion) +} + +/** + * Try to create a RelayList from an event, returns null if invalid + */ +export const tryToRelayList = ( + event: Event | null | undefined, + filterOutOnion = false +): RelayList | null => { + if (!event) return null + try { + return RelayList.fromEvent(event, filterOutOnion) + } catch { + return null + } +} + +/** + * Convert a RelayList to the legacy TRelayList format + */ +export const fromRelayListToLegacy = ( + relayList: RelayList +): { + read: string[] + write: string[] + originalRelays: Array<{ url: string; scope: RelayScope }> +} => { + return relayList.toLegacyFormat() +} + +/** + * Create a RelayList from legacy format + */ +export const toRelayListFromLegacy = ( + ownerHex: string, + legacy: { + read: string[] + write: string[] + originalRelays?: Array<{ url: string; scope: RelayScope }> + } +): RelayList | null => { + const { Pubkey } = require('../shared') + const owner = Pubkey.tryFromString(ownerHex) + if (!owner) return null + + const relayList = RelayList.empty(owner) + + if (legacy.originalRelays) { + for (const { url, scope } of legacy.originalRelays) { + relayList.setRelayUrl(url, scope) + } + } else { + // Reconstruct from read/write arrays + const readSet = new Set(legacy.read) + const writeSet = new Set(legacy.write) + const allUrls = new Set([...legacy.read, ...legacy.write]) + + for (const url of allUrls) { + const isRead = readSet.has(url) + const isWrite = writeSet.has(url) + let scope: RelayScope = 'both' + if (isRead && !isWrite) scope = 'read' + else if (!isRead && isWrite) scope = 'write' + + relayList.setRelayUrl(url, scope) + } + } + + return relayList +} + +// ============================================================================ +// RelaySet Adapters +// ============================================================================ + +/** + * Convert a Nostr event to a RelaySet domain object + */ +export const toRelaySet = (event: Event): RelaySet => { + return RelaySet.fromEvent(event) +} + +/** + * Try to create a RelaySet from an event, returns null if invalid + */ +export const tryToRelaySet = (event: Event | null | undefined): RelaySet | null => { + if (!event) return null + try { + return RelaySet.fromEvent(event) + } catch { + return null + } +} + +/** + * Convert a RelaySet to the legacy TRelaySet format + */ +export const fromRelaySetToLegacy = ( + relaySet: RelaySet, + pubkey: string +): { + id: string + aTag: string[] + name: string + relayUrls: string[] +} => { + return { + id: relaySet.id, + aTag: relaySet.toATag(pubkey), + name: relaySet.name, + relayUrls: relaySet.getRelayUrls() + } +} + +/** + * Create a RelaySet from legacy format + */ +export const toRelaySetFromLegacy = (legacy: { + id: string + name: string + relayUrls: string[] +}): RelaySet => { + return RelaySet.createWithRelays(legacy.name, legacy.relayUrls, legacy.id) +} + +// ============================================================================ +// FavoriteRelays Adapters +// ============================================================================ + +/** + * Convert events to a FavoriteRelays domain object + */ +export const toFavoriteRelays = ( + event: Event, + relaySetEvents: Event[] = [] +): FavoriteRelays => { + const relaySets = relaySetEvents.map((e) => tryToRelaySet(e)).filter(Boolean) as RelaySet[] + return FavoriteRelays.fromEvent(event, relaySets) +} + +/** + * Try to create FavoriteRelays from an event + */ +export const tryToFavoriteRelays = ( + event: Event | null | undefined, + relaySetEvents: Event[] = [] +): FavoriteRelays | null => { + if (!event) return null + try { + return toFavoriteRelays(event, relaySetEvents) + } catch { + return null + } +} + +// ============================================================================ +// Utility Adapters +// ============================================================================ + +/** + * Convert an array of URL strings to RelayUrl objects + * Skips invalid URLs + */ +export const urlsToRelayUrls = (urls: string[]): RelayUrl[] => { + return urls.map((u) => tryToRelayUrl(u)).filter((r): r is RelayUrl => r !== null) +} + +/** + * Convert an array of RelayUrl objects to URL strings + */ +export const relayUrlsToStrings = (relays: RelayUrl[]): string[] => { + return relays.map((r) => r.value) +} + +/** + * Normalize a relay URL string + * Returns null if invalid + */ +export const normalizeRelayUrl = (url: string): string | null => { + const relay = tryToRelayUrl(url) + return relay ? relay.value : null +} + +/** + * Check if a string is a valid WebSocket URL + */ +export const isValidRelayUrl = (url: string): boolean => { + return RelayUrl.isValid(url) +} diff --git a/src/domain/relay/errors.ts b/src/domain/relay/errors.ts new file mode 100644 index 00000000..2ec1fc8f --- /dev/null +++ b/src/domain/relay/errors.ts @@ -0,0 +1,41 @@ +/** + * Domain errors for Relay bounded context + */ + +import { DomainError } from '../shared' + +/** + * Thrown when a relay set operation fails + */ +export class RelaySetOperationError extends DomainError { + constructor(operation: string, reason?: string) { + super(`Relay set operation failed: ${operation}${reason ? ` - ${reason}` : ''}`) + } +} + +/** + * Thrown when a relay list operation fails + */ +export class RelayListOperationError extends DomainError { + constructor(operation: string, reason?: string) { + super(`Relay list operation failed: ${operation}${reason ? ` - ${reason}` : ''}`) + } +} + +/** + * Thrown when attempting to add a duplicate relay + */ +export class DuplicateRelayError extends DomainError { + constructor(url: string) { + super(`Relay already exists: ${url}`) + } +} + +/** + * Thrown when a relay is not found + */ +export class RelayNotFoundError extends DomainError { + constructor(url: string) { + super(`Relay not found: ${url}`) + } +} diff --git a/src/domain/relay/index.ts b/src/domain/relay/index.ts new file mode 100644 index 00000000..4ba9889b --- /dev/null +++ b/src/domain/relay/index.ts @@ -0,0 +1,52 @@ +/** + * Relay Bounded Context + * + * Handles relay management, relay sets, and relay preferences. + */ + +// Aggregates +export { RelaySet } from './RelaySet' +export type { RelaySetChange } from './RelaySet' + +export { RelayList } from './RelayList' +export type { RelayScope, RelayEntry, RelayListChange } from './RelayList' + +export { FavoriteRelays } from './FavoriteRelays' +export type { FavoriteRelaysChange } from './FavoriteRelays' + +// Errors +export { + RelaySetOperationError, + RelayListOperationError, + DuplicateRelayError, + RelayNotFoundError +} from './errors' + +// Repository Interfaces +export type { + RelayListRepository, + RelaySetRepository, + FavoriteRelaysRepository +} from './repositories' + +// Adapters for migration +export { + // RelayList adapters + toRelayList, + tryToRelayList, + fromRelayListToLegacy, + toRelayListFromLegacy, + // RelaySet adapters + toRelaySet, + tryToRelaySet, + fromRelaySetToLegacy, + toRelaySetFromLegacy, + // FavoriteRelays adapters + toFavoriteRelays, + tryToFavoriteRelays, + // Utility adapters + urlsToRelayUrls, + relayUrlsToStrings, + normalizeRelayUrl, + isValidRelayUrl +} from './adapters' diff --git a/src/domain/relay/repositories.ts b/src/domain/relay/repositories.ts new file mode 100644 index 00000000..614ec45c --- /dev/null +++ b/src/domain/relay/repositories.ts @@ -0,0 +1,64 @@ +import { Pubkey } from '../shared' +import { RelayList } from './RelayList' +import { RelaySet } from './RelaySet' +import { FavoriteRelays } from './FavoriteRelays' + +/** + * Repository interface for RelayList aggregate + * + * Implementations should handle: + * - Local caching (IndexedDB) + * - Remote fetching from relays + * - Event publishing + */ +export interface RelayListRepository { + /** + * Find the relay list for a user + */ + findByOwner(pubkey: Pubkey): Promise<RelayList | null> + + /** + * Save a relay list + */ + save(relayList: RelayList): Promise<void> +} + +/** + * Repository interface for RelaySet aggregate + */ +export interface RelaySetRepository { + /** + * Find a relay set by owner and ID + */ + findById(pubkey: Pubkey, id: string): Promise<RelaySet | null> + + /** + * Find all relay sets for a user + */ + findByOwner(pubkey: Pubkey): Promise<RelaySet[]> + + /** + * Save a relay set + */ + save(pubkey: Pubkey, relaySet: RelaySet): Promise<void> + + /** + * Delete a relay set + */ + delete(pubkey: Pubkey, id: string): Promise<void> +} + +/** + * Repository interface for FavoriteRelays aggregate + */ +export interface FavoriteRelaysRepository { + /** + * Find the favorite relays for a user + */ + findByOwner(pubkey: Pubkey): Promise<FavoriteRelays | null> + + /** + * Save favorite relays + */ + save(favoriteRelays: FavoriteRelays): Promise<void> +} diff --git a/src/domain/shared/adapters.ts b/src/domain/shared/adapters.ts new file mode 100644 index 00000000..47f71942 --- /dev/null +++ b/src/domain/shared/adapters.ts @@ -0,0 +1,174 @@ +/** + * Adapter functions for gradual migration from primitives to value objects. + * + * These functions allow existing code to continue using string primitives + * while new code can use value objects. Use these at the boundaries + * between old and new code. + */ + +import { Pubkey, RelayUrl, EventId, Timestamp } from './value-objects' + +// ============================================================================ +// Pubkey Adapters +// ============================================================================ + +/** + * Convert a hex string to a Pubkey value object. + * @throws InvalidPubkeyError if invalid + */ +export const toPubkey = (hex: string): Pubkey => Pubkey.fromHex(hex) + +/** + * Try to convert a string (hex, npub, nprofile) to a Pubkey. + * Returns null if invalid. + */ +export const tryToPubkey = (input: string): Pubkey | null => Pubkey.tryFromString(input) + +/** + * Convert a Pubkey back to a hex string for interop with existing code. + */ +export const fromPubkey = (pubkey: Pubkey): string => pubkey.hex + +/** + * Convert an array of hex strings to Pubkeys. + * Skips invalid entries. + */ +export const toPubkeys = (hexes: string[]): Pubkey[] => + hexes.map((h) => Pubkey.tryFromString(h)).filter((p): p is Pubkey => p !== null) + +/** + * Convert an array of Pubkeys to hex strings. + */ +export const fromPubkeys = (pubkeys: Pubkey[]): string[] => pubkeys.map((p) => p.hex) + +// ============================================================================ +// RelayUrl Adapters +// ============================================================================ + +/** + * Convert a URL string to a RelayUrl value object. + * @throws InvalidRelayUrlError if invalid + */ +export const toRelayUrl = (url: string): RelayUrl => RelayUrl.create(url) + +/** + * Try to convert a URL string to a RelayUrl. + * Returns null if invalid. + */ +export const tryToRelayUrl = (url: string): RelayUrl | null => RelayUrl.tryCreate(url) + +/** + * Convert a RelayUrl back to a string for interop with existing code. + */ +export const fromRelayUrl = (relay: RelayUrl): string => relay.value + +/** + * Convert an array of URL strings to RelayUrls. + * Skips invalid entries. + */ +export const toRelayUrls = (urls: string[]): RelayUrl[] => + urls.map((u) => RelayUrl.tryCreate(u)).filter((r): r is RelayUrl => r !== null) + +/** + * Convert an array of RelayUrls to strings. + */ +export const fromRelayUrls = (relays: RelayUrl[]): string[] => relays.map((r) => r.value) + +// ============================================================================ +// EventId Adapters +// ============================================================================ + +/** + * Convert a hex string to an EventId value object. + * @throws InvalidEventIdError if invalid + */ +export const toEventId = (hex: string): EventId => EventId.fromHex(hex) + +/** + * Try to convert a string (hex, note1, nevent1) to an EventId. + * Returns null if invalid. + */ +export const tryToEventId = (input: string): EventId | null => EventId.tryFromString(input) + +/** + * Convert an EventId back to a hex string for interop with existing code. + */ +export const fromEventId = (eventId: EventId): string => eventId.hex + +/** + * Convert an array of hex strings to EventIds. + * Skips invalid entries. + */ +export const toEventIds = (hexes: string[]): EventId[] => + hexes.map((h) => EventId.tryFromString(h)).filter((e): e is EventId => e !== null) + +/** + * Convert an array of EventIds to hex strings. + */ +export const fromEventIds = (eventIds: EventId[]): string[] => eventIds.map((e) => e.hex) + +// ============================================================================ +// Timestamp Adapters +// ============================================================================ + +/** + * Convert a Unix timestamp (seconds) to a Timestamp value object. + * @throws InvalidTimestampError if invalid + */ +export const toTimestamp = (unix: number): Timestamp => Timestamp.fromUnix(unix) + +/** + * Try to convert a Unix timestamp to a Timestamp. + * Returns null if invalid. + */ +export const tryToTimestamp = (unix: number): Timestamp | null => Timestamp.tryFromUnix(unix) + +/** + * Convert a Timestamp back to a Unix number for interop with existing code. + */ +export const fromTimestamp = (timestamp: Timestamp): number => timestamp.unix + +// ============================================================================ +// Set Adapters (for mute lists, follow lists, etc.) +// ============================================================================ + +/** + * Convert a Set of hex strings to a Set wrapper that uses Pubkey for lookups. + */ +export const createPubkeySet = ( + hexSet: Set<string> +): { + has: (pubkey: Pubkey) => boolean + add: (pubkey: Pubkey) => void + delete: (pubkey: Pubkey) => boolean + toHexSet: () => Set<string> + toPubkeys: () => Pubkey[] +} => ({ + has: (pubkey: Pubkey) => hexSet.has(pubkey.hex), + add: (pubkey: Pubkey) => hexSet.add(pubkey.hex), + delete: (pubkey: Pubkey) => hexSet.delete(pubkey.hex), + toHexSet: () => hexSet, + toPubkeys: () => Array.from(hexSet).map((h) => Pubkey.fromHex(h)), +}) + +/** + * Convert a Set of URL strings to a Set wrapper that uses RelayUrl for lookups. + */ +export const createRelayUrlSet = ( + urlSet: Set<string> +): { + has: (relay: RelayUrl) => boolean + add: (relay: RelayUrl) => void + delete: (relay: RelayUrl) => boolean + toStringSet: () => Set<string> + toRelayUrls: () => RelayUrl[] +} => ({ + has: (relay: RelayUrl) => urlSet.has(relay.value), + add: (relay: RelayUrl) => urlSet.add(relay.value), + delete: (relay: RelayUrl) => urlSet.delete(relay.value), + toStringSet: () => urlSet, + toRelayUrls: () => + Array.from(urlSet) + .map((u) => RelayUrl.tryCreate(u)) + .filter((r): r is RelayUrl => r !== null), +}) diff --git a/src/domain/shared/errors.ts b/src/domain/shared/errors.ts new file mode 100644 index 00000000..d731a443 --- /dev/null +++ b/src/domain/shared/errors.ts @@ -0,0 +1,34 @@ +/** + * Domain errors for value object validation + */ + +export class DomainError extends Error { + constructor(message: string) { + super(message) + this.name = this.constructor.name + } +} + +export class InvalidPubkeyError extends DomainError { + constructor(value: string) { + super(`Invalid pubkey: "${value.slice(0, 20)}${value.length > 20 ? '...' : ''}"`) + } +} + +export class InvalidRelayUrlError extends DomainError { + constructor(value: string) { + super(`Invalid relay URL: "${value}"`) + } +} + +export class InvalidEventIdError extends DomainError { + constructor(value: string) { + super(`Invalid event ID: "${value.slice(0, 20)}${value.length > 20 ? '...' : ''}"`) + } +} + +export class InvalidTimestampError extends DomainError { + constructor(value: number) { + super(`Invalid timestamp: ${value}`) + } +} diff --git a/src/domain/shared/index.ts b/src/domain/shared/index.ts new file mode 100644 index 00000000..4e806f94 --- /dev/null +++ b/src/domain/shared/index.ts @@ -0,0 +1,47 @@ +/** + * Domain Shared Kernel + * + * Common value objects, errors, and adapters used across all bounded contexts. + */ + +// Value Objects +export { + Pubkey, + RelayUrl, + EventId, + Timestamp, + InvalidPubkeyError, + InvalidRelayUrlError, + InvalidEventIdError, + InvalidTimestampError, + DomainError, +} from './value-objects' + +// Adapters for migration +export { + // Pubkey + toPubkey, + tryToPubkey, + fromPubkey, + toPubkeys, + fromPubkeys, + // RelayUrl + toRelayUrl, + tryToRelayUrl, + fromRelayUrl, + toRelayUrls, + fromRelayUrls, + // EventId + toEventId, + tryToEventId, + fromEventId, + toEventIds, + fromEventIds, + // Timestamp + toTimestamp, + tryToTimestamp, + fromTimestamp, + // Set helpers + createPubkeySet, + createRelayUrlSet, +} from './adapters' diff --git a/src/domain/shared/value-objects/EventId.ts b/src/domain/shared/value-objects/EventId.ts new file mode 100644 index 00000000..ee1679de --- /dev/null +++ b/src/domain/shared/value-objects/EventId.ts @@ -0,0 +1,146 @@ +import { nip19 } from 'nostr-tools' +import { InvalidEventIdError } from '../errors' +import { Pubkey } from './Pubkey' +import { RelayUrl } from './RelayUrl' + +/** + * Value object representing a Nostr event ID. + * Can be created from hex or bech32 (note1/nevent1) formats. + * Optionally includes relay hints and author information. + */ +export class EventId { + private constructor( + private readonly _hex: string, + private readonly _kind?: number, + private readonly _author?: Pubkey, + private readonly _relayHints: RelayUrl[] = [] + ) {} + + /** + * Create an EventId from a 64-character hex string. + * @throws InvalidEventIdError if the hex is invalid + */ + static fromHex(hex: string): EventId { + if (!/^[0-9a-f]{64}$/.test(hex)) { + throw new InvalidEventIdError(hex) + } + return new EventId(hex) + } + + /** + * Create an EventId from a bech32 string (note1 or nevent1). + * @throws InvalidEventIdError if the bech32 is invalid + */ + static fromBech32(bech32: string): EventId { + try { + const { type, data } = nip19.decode(bech32) + + switch (type) { + case 'note': + return new EventId(data) + + case 'nevent': { + const relayHints = (data.relays || []) + .map((r) => RelayUrl.tryCreate(r)) + .filter((r): r is RelayUrl => r !== null) + + return new EventId( + data.id, + data.kind, + data.author ? Pubkey.tryFromString(data.author) || undefined : undefined, + relayHints + ) + } + + default: + throw new InvalidEventIdError(bech32) + } + } catch (e) { + if (e instanceof InvalidEventIdError) throw e + throw new InvalidEventIdError(bech32) + } + } + + /** + * Try to create an EventId from any string format (hex, note1, nevent1). + * Returns null if the string is invalid. + */ + static tryFromString(input: string): EventId | null { + try { + if (input.startsWith('note1') || input.startsWith('nevent1')) { + return EventId.fromBech32(input) + } + return EventId.fromHex(input) + } catch { + return null + } + } + + /** + * Check if a string is a valid event ID (hex format only). + */ + static isValidHex(value: string): boolean { + return /^[0-9a-f]{64}$/.test(value) + } + + /** The raw 64-character hex value */ + get hex(): string { + return this._hex + } + + /** The event kind if known (from nevent) */ + get kind(): number | undefined { + return this._kind + } + + /** The event author if known (from nevent) */ + get author(): Pubkey | undefined { + return this._author + } + + /** Relay hints for finding this event */ + get relayHints(): readonly RelayUrl[] { + return this._relayHints + } + + /** A shortened display format */ + get formatted(): string { + return `${this._hex.slice(0, 8)}...${this._hex.slice(-4)}` + } + + /** + * Convert to bech32 format. + * Returns nevent1 if there's additional metadata, otherwise note1. + */ + toBech32(): string { + if (this._kind !== undefined || this._author || this._relayHints.length > 0) { + return nip19.neventEncode({ + id: this._hex, + kind: this._kind, + author: this._author?.hex, + relays: this._relayHints.map((r) => r.value), + }) + } + return nip19.noteEncode(this._hex) + } + + /** Convert to simple note1 format (no metadata) */ + toNote(): string { + return nip19.noteEncode(this._hex) + } + + /** Check equality with another EventId (compares hex only) */ + equals(other: EventId): boolean { + return this._hex === other._hex + } + + /** Returns the hex representation */ + toString(): string { + return this._hex + } + + /** For JSON serialization */ + toJSON(): string { + return this._hex + } +} diff --git a/src/domain/shared/value-objects/Pubkey.ts b/src/domain/shared/value-objects/Pubkey.ts new file mode 100644 index 00000000..73596b37 --- /dev/null +++ b/src/domain/shared/value-objects/Pubkey.ts @@ -0,0 +1,121 @@ +import { nip19 } from 'nostr-tools' +import { InvalidPubkeyError } from '../errors' + +/** + * Value object representing a Nostr public key. + * Immutable, self-validating, with equality by value. + */ +export class Pubkey { + private constructor(private readonly _value: string) {} + + /** + * Create a Pubkey from a 64-character hex string. + * @throws InvalidPubkeyError if the hex is invalid + */ + static fromHex(hex: string): Pubkey { + if (!/^[0-9a-f]{64}$/.test(hex)) { + throw new InvalidPubkeyError(hex) + } + return new Pubkey(hex) + } + + /** + * Create a Pubkey from an npub bech32 string. + * @throws InvalidPubkeyError if the npub is invalid + */ + static fromNpub(npub: string): Pubkey { + try { + const { type, data } = nip19.decode(npub) + if (type !== 'npub') { + throw new InvalidPubkeyError(npub) + } + return new Pubkey(data) + } catch (e) { + if (e instanceof InvalidPubkeyError) throw e + throw new InvalidPubkeyError(npub) + } + } + + /** + * Create a Pubkey from an nprofile bech32 string. + * @throws InvalidPubkeyError if the nprofile is invalid + */ + static fromNprofile(nprofile: string): Pubkey { + try { + const { type, data } = nip19.decode(nprofile) + if (type !== 'nprofile') { + throw new InvalidPubkeyError(nprofile) + } + return new Pubkey(data.pubkey) + } catch (e) { + if (e instanceof InvalidPubkeyError) throw e + throw new InvalidPubkeyError(nprofile) + } + } + + /** + * Try to create a Pubkey from any string format (hex, npub, nprofile). + * Returns null if the string is invalid. + */ + static tryFromString(input: string): Pubkey | null { + try { + if (input.startsWith('npub1')) { + return Pubkey.fromNpub(input) + } + if (input.startsWith('nprofile1')) { + return Pubkey.fromNprofile(input) + } + return Pubkey.fromHex(input) + } catch { + return null + } + } + + /** + * Check if a string is a valid pubkey (hex format only). + */ + static isValidHex(value: string): boolean { + return /^[0-9a-f]{64}$/.test(value) + } + + /** The raw 64-character hex value */ + get hex(): string { + return this._value + } + + /** The bech32-encoded npub representation */ + get npub(): string { + return nip19.npubEncode(this._value) + } + + /** A shortened display format: first 8 + ... + last 4 characters */ + get formatted(): string { + return `${this._value.slice(0, 8)}...${this._value.slice(-4)}` + } + + /** A shorter display format for the npub */ + formatNpub(length = 12): string { + const npub = this.npub + if (length < 12) length = 12 + if (length >= 63) return npub + + const prefixLength = Math.floor((length - 5) / 2) + 5 + const suffixLength = length - prefixLength + return npub.slice(0, prefixLength) + '...' + npub.slice(-suffixLength) + } + + /** Check equality with another Pubkey */ + equals(other: Pubkey): boolean { + return this._value === other._value + } + + /** Returns the hex representation */ + toString(): string { + return this._value + } + + /** For JSON serialization */ + toJSON(): string { + return this._value + } +} diff --git a/src/domain/shared/value-objects/RelayUrl.ts b/src/domain/shared/value-objects/RelayUrl.ts new file mode 100644 index 00000000..85fc3c6d --- /dev/null +++ b/src/domain/shared/value-objects/RelayUrl.ts @@ -0,0 +1,117 @@ +import { InvalidRelayUrlError } from '../errors' + +/** + * Value object representing a normalized Nostr relay WebSocket URL. + * Immutable, self-validating, with equality by value. + */ +export class RelayUrl { + private constructor(private readonly _value: string) {} + + /** + * Create a RelayUrl from a WebSocket URL string. + * Normalizes the URL (lowercases, removes trailing slash). + * @throws InvalidRelayUrlError if the URL is invalid + */ + static create(url: string): RelayUrl { + const normalized = RelayUrl.normalize(url) + if (!normalized) { + throw new InvalidRelayUrlError(url) + } + return new RelayUrl(normalized) + } + + /** + * Try to create a RelayUrl. Returns null if invalid. + */ + static tryCreate(url: string): RelayUrl | null { + try { + return RelayUrl.create(url) + } catch { + return null + } + } + + /** + * Check if a string is a valid WebSocket URL. + */ + static isValid(url: string): boolean { + return RelayUrl.normalize(url) !== null + } + + private static normalize(url: string): string | null { + try { + const trimmed = url.trim() + if (!trimmed) return null + + const parsed = new URL(trimmed) + if (!['ws:', 'wss:'].includes(parsed.protocol)) { + return null + } + + // Normalize: lowercase host, remove trailing slash, keep path + let normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}` + if (parsed.pathname && parsed.pathname !== '/') { + normalized += parsed.pathname.replace(/\/$/, '') + } + + return normalized + } catch { + return null + } + } + + /** The normalized URL string */ + get value(): string { + return this._value + } + + /** The URL without the protocol prefix */ + get shortForm(): string { + return this._value.replace(/^wss?:\/\//, '') + } + + /** Whether this is a secure (wss://) connection */ + get isSecure(): boolean { + return this._value.startsWith('wss:') + } + + /** Whether this is a Tor onion address */ + get isOnion(): boolean { + return this._value.includes('.onion') + } + + /** Whether this is a local network address */ + get isLocalNetwork(): boolean { + return ( + this._value.includes('localhost') || + this._value.includes('127.0.0.1') || + this._value.includes('192.168.') || + this._value.includes('10.') || + this._value.includes('172.16.') + ) + } + + /** Extract the hostname from the URL */ + get hostname(): string { + try { + return new URL(this._value).hostname + } catch { + return this._value + } + } + + /** Check equality with another RelayUrl */ + equals(other: RelayUrl): boolean { + return this._value === other._value + } + + /** Returns the URL string */ + toString(): string { + return this._value + } + + /** For JSON serialization */ + toJSON(): string { + return this._value + } +} diff --git a/src/domain/shared/value-objects/Timestamp.ts b/src/domain/shared/value-objects/Timestamp.ts new file mode 100644 index 00000000..a517064d --- /dev/null +++ b/src/domain/shared/value-objects/Timestamp.ts @@ -0,0 +1,188 @@ +import { InvalidTimestampError } from '../errors' + +/** + * Value object representing a Unix timestamp (seconds since epoch). + * Immutable, self-validating, with formatting utilities. + */ +export class Timestamp { + private constructor(private readonly _unix: number) {} + + /** + * Create a Timestamp for the current time. + */ + static now(): Timestamp { + return new Timestamp(Math.floor(Date.now() / 1000)) + } + + /** + * Create a Timestamp from a Unix timestamp (seconds). + * @throws InvalidTimestampError if the value is negative + */ + static fromUnix(unix: number): Timestamp { + if (unix < 0 || !Number.isFinite(unix)) { + throw new InvalidTimestampError(unix) + } + return new Timestamp(Math.floor(unix)) + } + + /** + * Create a Timestamp from a Date object. + */ + static fromDate(date: Date): Timestamp { + const unix = Math.floor(date.getTime() / 1000) + if (unix < 0) { + throw new InvalidTimestampError(unix) + } + return new Timestamp(unix) + } + + /** + * Create a Timestamp from milliseconds since epoch. + */ + static fromMillis(millis: number): Timestamp { + return Timestamp.fromUnix(millis / 1000) + } + + /** + * Try to create a Timestamp. Returns null if invalid. + */ + static tryFromUnix(unix: number): Timestamp | null { + try { + return Timestamp.fromUnix(unix) + } catch { + return null + } + } + + /** The Unix timestamp in seconds */ + get unix(): number { + return this._unix + } + + /** The timestamp as milliseconds since epoch */ + get millis(): number { + return this._unix * 1000 + } + + /** Convert to a Date object */ + get date(): Date { + return new Date(this._unix * 1000) + } + + /** Check if this timestamp is before another */ + isBefore(other: Timestamp): boolean { + return this._unix < other._unix + } + + /** Check if this timestamp is after another */ + isAfter(other: Timestamp): boolean { + return this._unix > other._unix + } + + /** Check if this timestamp is in the future */ + isFuture(): boolean { + return this._unix > Timestamp.now()._unix + } + + /** Check if this timestamp is in the past */ + isPast(): boolean { + return this._unix < Timestamp.now()._unix + } + + /** Get the difference in seconds from another timestamp */ + secondsFrom(other: Timestamp): number { + return this._unix - other._unix + } + + /** Create a new Timestamp by adding seconds */ + addSeconds(seconds: number): Timestamp { + return new Timestamp(this._unix + seconds) + } + + /** Create a new Timestamp by adding minutes */ + addMinutes(minutes: number): Timestamp { + return this.addSeconds(minutes * 60) + } + + /** Create a new Timestamp by adding hours */ + addHours(hours: number): Timestamp { + return this.addSeconds(hours * 3600) + } + + /** Create a new Timestamp by adding days */ + addDays(days: number): Timestamp { + return this.addSeconds(days * 86400) + } + + /** + * Format as a relative time string (e.g., "5m", "2h", "3d"). + */ + formatRelative(): string { + const now = Timestamp.now() + const diff = now._unix - this._unix + + if (diff < 0) { + return 'in the future' + } + if (diff < 60) { + return 'just now' + } + if (diff < 3600) { + return `${Math.floor(diff / 60)}m` + } + if (diff < 86400) { + return `${Math.floor(diff / 3600)}h` + } + if (diff < 604800) { + return `${Math.floor(diff / 86400)}d` + } + if (diff < 2592000) { + return `${Math.floor(diff / 604800)}w` + } + if (diff < 31536000) { + return `${Math.floor(diff / 2592000)}mo` + } + return `${Math.floor(diff / 31536000)}y` + } + + /** + * Format as ISO 8601 string. + */ + toISOString(): string { + return this.date.toISOString() + } + + /** + * Format as locale date string. + */ + toLocaleDateString(locales?: string | string[], options?: Intl.DateTimeFormatOptions): string { + return this.date.toLocaleDateString(locales, options) + } + + /** + * Format as locale time string. + */ + toLocaleTimeString(locales?: string | string[], options?: Intl.DateTimeFormatOptions): string { + return this.date.toLocaleTimeString(locales, options) + } + + /** Check equality with another Timestamp */ + equals(other: Timestamp): boolean { + return this._unix === other._unix + } + + /** Returns the Unix timestamp as a number */ + valueOf(): number { + return this._unix + } + + /** Returns the Unix timestamp as a string */ + toString(): string { + return String(this._unix) + } + + /** For JSON serialization */ + toJSON(): number { + return this._unix + } +} diff --git a/src/domain/shared/value-objects/index.ts b/src/domain/shared/value-objects/index.ts new file mode 100644 index 00000000..fb7a75f2 --- /dev/null +++ b/src/domain/shared/value-objects/index.ts @@ -0,0 +1,20 @@ +/** + * Domain Value Objects + * + * Self-validating, immutable value objects that replace primitive types. + * These provide type safety and encapsulate validation logic. + */ + +export { Pubkey } from './Pubkey' +export { RelayUrl } from './RelayUrl' +export { EventId } from './EventId' +export { Timestamp } from './Timestamp' + +// Re-export errors for convenience +export { + InvalidPubkeyError, + InvalidRelayUrlError, + InvalidEventIdError, + InvalidTimestampError, + DomainError, +} from '../errors' diff --git a/src/domain/social/FollowList.ts b/src/domain/social/FollowList.ts new file mode 100644 index 00000000..224350af --- /dev/null +++ b/src/domain/social/FollowList.ts @@ -0,0 +1,229 @@ +import { Event, kinds } from 'nostr-tools' +import { Pubkey, Timestamp } from '../shared' +import { CannotFollowSelfError } from './errors' + +/** + * Represents a petname entry with relay hint + */ +export type FollowEntry = { + pubkey: Pubkey + relayHint?: string + petname?: string +} + +/** + * Result of a follow/unfollow operation + */ +export type FollowListChange = + | { type: 'added'; pubkey: Pubkey } + | { type: 'removed'; pubkey: Pubkey } + | { type: 'no_change' } + +/** + * FollowList Aggregate + * + * Represents a user's contact list (kind 3 event in Nostr). + * Encapsulates all business rules for following/unfollowing users. + * + * Invariants: + * - Cannot follow self + * - No duplicate entries + * - Pubkeys must be valid + */ +export class FollowList { + private readonly _entries: Map<string, FollowEntry> + private readonly _content: string + + private constructor( + private readonly _owner: Pubkey, + entries: FollowEntry[], + content: string = '' + ) { + this._entries = new Map() + for (const entry of entries) { + this._entries.set(entry.pubkey.hex, entry) + } + this._content = content + } + + /** + * Create an empty FollowList for a user + */ + static empty(owner: Pubkey): FollowList { + return new FollowList(owner, []) + } + + /** + * Reconstruct a FollowList from a Nostr kind 3 event + */ + static fromEvent(event: Event): FollowList { + if (event.kind !== kinds.Contacts) { + throw new Error(`Expected kind ${kinds.Contacts}, got ${event.kind}`) + } + + const owner = Pubkey.fromHex(event.pubkey) + const entries: FollowEntry[] = [] + + for (const tag of event.tags) { + if (tag[0] === 'p' && tag[1]) { + const pubkey = Pubkey.tryFromString(tag[1]) + if (pubkey) { + entries.push({ + pubkey, + relayHint: tag[2] || undefined, + petname: tag[3] || undefined + }) + } + } + } + + return new FollowList(owner, entries, event.content) + } + + /** + * The owner of this follow list + */ + get owner(): Pubkey { + return this._owner + } + + /** + * Number of users being followed + */ + get count(): number { + return this._entries.size + } + + /** + * The raw content field (may contain relay preferences in legacy format) + */ + get content(): string { + return this._content + } + + /** + * Get all followed pubkeys + */ + getFollowing(): Pubkey[] { + return Array.from(this._entries.values()).map((e) => e.pubkey) + } + + /** + * Get all follow entries with metadata + */ + getEntries(): FollowEntry[] { + return Array.from(this._entries.values()) + } + + /** + * Check if a user is being followed + */ + isFollowing(pubkey: Pubkey): boolean { + return this._entries.has(pubkey.hex) + } + + /** + * Get the entry for a followed user + */ + getEntry(pubkey: Pubkey): FollowEntry | undefined { + return this._entries.get(pubkey.hex) + } + + /** + * Follow a user + * + * @throws CannotFollowSelfError if attempting to follow self + * @returns FollowListChange indicating what changed + */ + follow(pubkey: Pubkey, relayHint?: string, petname?: string): FollowListChange { + if (pubkey.equals(this._owner)) { + throw new CannotFollowSelfError() + } + + if (this._entries.has(pubkey.hex)) { + return { type: 'no_change' } + } + + this._entries.set(pubkey.hex, { pubkey, relayHint, petname }) + return { type: 'added', pubkey } + } + + /** + * Unfollow a user + * + * @returns FollowListChange indicating what changed + */ + unfollow(pubkey: Pubkey): FollowListChange { + if (!this._entries.has(pubkey.hex)) { + return { type: 'no_change' } + } + + this._entries.delete(pubkey.hex) + return { type: 'removed', pubkey } + } + + /** + * Update petname for a followed user + * + * @returns true if updated, false if user not found + */ + setPetname(pubkey: Pubkey, petname: string | undefined): boolean { + const entry = this._entries.get(pubkey.hex) + if (!entry) { + return false + } + + this._entries.set(pubkey.hex, { ...entry, petname }) + return true + } + + /** + * Convert to Nostr event tags format + */ + toTags(): string[][] { + return Array.from(this._entries.values()).map((entry) => { + const tag = ['p', entry.pubkey.hex] + if (entry.relayHint) { + tag.push(entry.relayHint) + if (entry.petname) { + tag.push(entry.petname) + } + } else if (entry.petname) { + tag.push('', entry.petname) + } + return tag + }) + } + + /** + * Convert to a draft event for publishing + */ + toDraftEvent(): { kind: number; content: string; created_at: number; tags: string[][] } { + return { + kind: kinds.Contacts, + content: this._content, + created_at: Timestamp.now().unix, + tags: this.toTags() + } + } + + /** + * Create a new FollowList with the same entries but different owner + * Useful for importing someone else's follow list + */ + cloneFor(newOwner: Pubkey): FollowList { + const entries = this.getEntries().filter((e) => !e.pubkey.equals(newOwner)) + return new FollowList(newOwner, entries, this._content) + } + + /** + * Merge another follow list into this one (union of both) + */ + merge(other: FollowList): void { + for (const entry of other.getEntries()) { + if (!entry.pubkey.equals(this._owner) && !this._entries.has(entry.pubkey.hex)) { + this._entries.set(entry.pubkey.hex, entry) + } + } + } +} diff --git a/src/domain/social/MuteList.ts b/src/domain/social/MuteList.ts new file mode 100644 index 00000000..3fe31aa8 --- /dev/null +++ b/src/domain/social/MuteList.ts @@ -0,0 +1,307 @@ +import { Event, kinds } from 'nostr-tools' +import { Pubkey, Timestamp } from '../shared' +import { CannotMuteSelfError } from './errors' + +/** + * Type of mute visibility + */ +export type MuteVisibility = 'public' | 'private' + +/** + * A muted entry (user or other content) + */ +export type MuteEntry = { + pubkey: Pubkey + visibility: MuteVisibility +} + +/** + * Result of a mute/unmute operation + */ +export type MuteListChange = + | { type: 'muted'; pubkey: Pubkey; visibility: MuteVisibility } + | { type: 'unmuted'; pubkey: Pubkey } + | { type: 'visibility_changed'; pubkey: Pubkey; from: MuteVisibility; to: MuteVisibility } + | { type: 'no_change' } + +/** + * MuteList Aggregate + * + * Represents a user's mute list (kind 10000 event in Nostr). + * Supports both public mutes (visible to others) and private mutes (encrypted). + * + * Invariants: + * - Cannot mute self + * - No duplicate entries (a user can only be muted once, either public or private) + * - Pubkeys must be valid + * + * Note: The encryption/decryption of private mutes is handled at the infrastructure layer. + * This aggregate works with already-decrypted private tags. + */ +export class MuteList { + private readonly _publicMutes: Map<string, Pubkey> + private readonly _privateMutes: Map<string, Pubkey> + + private constructor( + private readonly _owner: Pubkey, + publicMutes: Pubkey[], + privateMutes: Pubkey[] + ) { + this._publicMutes = new Map() + this._privateMutes = new Map() + + for (const pubkey of publicMutes) { + this._publicMutes.set(pubkey.hex, pubkey) + } + for (const pubkey of privateMutes) { + this._privateMutes.set(pubkey.hex, pubkey) + } + } + + /** + * Create an empty MuteList for a user + */ + static empty(owner: Pubkey): MuteList { + return new MuteList(owner, [], []) + } + + /** + * Reconstruct a MuteList from a Nostr kind 10000 event + * + * @param event The mute list event + * @param decryptedPrivateTags The decrypted private tags (if any) + */ + static fromEvent(event: Event, decryptedPrivateTags: string[][] = []): MuteList { + if (event.kind !== kinds.Mutelist) { + throw new Error(`Expected kind ${kinds.Mutelist}, got ${event.kind}`) + } + + const owner = Pubkey.fromHex(event.pubkey) + const publicMutes: Pubkey[] = [] + const privateMutes: Pubkey[] = [] + + // Extract public mutes from event tags + for (const tag of event.tags) { + if (tag[0] === 'p' && tag[1]) { + const pubkey = Pubkey.tryFromString(tag[1]) + if (pubkey) { + publicMutes.push(pubkey) + } + } + } + + // Extract private mutes from decrypted content + for (const tag of decryptedPrivateTags) { + if (tag[0] === 'p' && tag[1]) { + const pubkey = Pubkey.tryFromString(tag[1]) + if (pubkey) { + privateMutes.push(pubkey) + } + } + } + + return new MuteList(owner, publicMutes, privateMutes) + } + + /** + * The owner of this mute list + */ + get owner(): Pubkey { + return this._owner + } + + /** + * Total number of muted users + */ + get count(): number { + return this._publicMutes.size + this._privateMutes.size + } + + /** + * Number of publicly muted users + */ + get publicCount(): number { + return this._publicMutes.size + } + + /** + * Number of privately muted users + */ + get privateCount(): number { + return this._privateMutes.size + } + + /** + * Get all muted pubkeys (both public and private) + */ + getAllMuted(): Pubkey[] { + return [...this.getPublicMuted(), ...this.getPrivateMuted()] + } + + /** + * Get publicly muted pubkeys + */ + getPublicMuted(): Pubkey[] { + return Array.from(this._publicMutes.values()) + } + + /** + * Get privately muted pubkeys + */ + getPrivateMuted(): Pubkey[] { + return Array.from(this._privateMutes.values()) + } + + /** + * Check if a user is muted (either publicly or privately) + */ + isMuted(pubkey: Pubkey): boolean { + return this._publicMutes.has(pubkey.hex) || this._privateMutes.has(pubkey.hex) + } + + /** + * Get the mute visibility for a user + */ + getMuteVisibility(pubkey: Pubkey): MuteVisibility | null { + if (this._publicMutes.has(pubkey.hex)) return 'public' + if (this._privateMutes.has(pubkey.hex)) return 'private' + return null + } + + /** + * Mute a user publicly + * + * @throws CannotMuteSelfError if attempting to mute self + * @returns MuteListChange indicating what changed + */ + mutePublicly(pubkey: Pubkey): MuteListChange { + if (pubkey.equals(this._owner)) { + throw new CannotMuteSelfError() + } + + // Already publicly muted + if (this._publicMutes.has(pubkey.hex)) { + return { type: 'no_change' } + } + + // Was privately muted, switch to public + if (this._privateMutes.has(pubkey.hex)) { + this._privateMutes.delete(pubkey.hex) + this._publicMutes.set(pubkey.hex, pubkey) + return { type: 'visibility_changed', pubkey, from: 'private', to: 'public' } + } + + // New public mute + this._publicMutes.set(pubkey.hex, pubkey) + return { type: 'muted', pubkey, visibility: 'public' } + } + + /** + * Mute a user privately + * + * @throws CannotMuteSelfError if attempting to mute self + * @returns MuteListChange indicating what changed + */ + mutePrivately(pubkey: Pubkey): MuteListChange { + if (pubkey.equals(this._owner)) { + throw new CannotMuteSelfError() + } + + // Already privately muted + if (this._privateMutes.has(pubkey.hex)) { + return { type: 'no_change' } + } + + // Was publicly muted, switch to private + if (this._publicMutes.has(pubkey.hex)) { + this._publicMutes.delete(pubkey.hex) + this._privateMutes.set(pubkey.hex, pubkey) + return { type: 'visibility_changed', pubkey, from: 'public', to: 'private' } + } + + // New private mute + this._privateMutes.set(pubkey.hex, pubkey) + return { type: 'muted', pubkey, visibility: 'private' } + } + + /** + * Unmute a user (removes from both public and private) + * + * @returns MuteListChange indicating what changed + */ + unmute(pubkey: Pubkey): MuteListChange { + if (this._publicMutes.has(pubkey.hex)) { + this._publicMutes.delete(pubkey.hex) + return { type: 'unmuted', pubkey } + } + + if (this._privateMutes.has(pubkey.hex)) { + this._privateMutes.delete(pubkey.hex) + return { type: 'unmuted', pubkey } + } + + return { type: 'no_change' } + } + + /** + * Switch a public mute to private + * + * @returns MuteListChange indicating what changed + */ + switchToPrivate(pubkey: Pubkey): MuteListChange { + return this.mutePrivately(pubkey) + } + + /** + * Switch a private mute to public + * + * @returns MuteListChange indicating what changed + */ + switchToPublic(pubkey: Pubkey): MuteListChange { + return this.mutePublicly(pubkey) + } + + /** + * Convert public mutes to Nostr event tags format + */ + toPublicTags(): string[][] { + return Array.from(this._publicMutes.values()).map((pubkey) => ['p', pubkey.hex]) + } + + /** + * Convert private mutes to tags format (for encryption) + */ + toPrivateTags(): string[][] { + return Array.from(this._privateMutes.values()).map((pubkey) => ['p', pubkey.hex]) + } + + /** + * Convert to a draft event for publishing + * + * Note: The content field should be encrypted by the caller using NIP-04 + * with JSON.stringify(this.toPrivateTags()) + * + * @param encryptedContent The NIP-04 encrypted private tags + */ + toDraftEvent(encryptedContent: string = ''): { + kind: number + content: string + created_at: number + tags: string[][] + } { + return { + kind: kinds.Mutelist, + content: encryptedContent, + created_at: Timestamp.now().unix, + tags: this.toPublicTags() + } + } + + /** + * Check if private mutes need to be encrypted/updated + * Returns true if there are private mutes that need to be persisted + */ + hasPrivateMutes(): boolean { + return this._privateMutes.size > 0 + } +} diff --git a/src/domain/social/adapters.ts b/src/domain/social/adapters.ts new file mode 100644 index 00000000..c18327aa --- /dev/null +++ b/src/domain/social/adapters.ts @@ -0,0 +1,225 @@ +/** + * Adapter functions for gradual migration from legacy code to Social domain objects. + * + * These functions allow existing providers and services to continue working + * while new code can use the rich domain objects. + */ + +import { Event } from 'nostr-tools' +import { tryToPubkey } from '../shared' +import { FollowList } from './FollowList' +import { MuteList, MuteVisibility } from './MuteList' + +// ============================================================================ +// FollowList Adapters +// ============================================================================ + +/** + * Convert a Nostr event to a FollowList domain object + */ +export const toFollowList = (event: Event): FollowList => { + return FollowList.fromEvent(event) +} + +/** + * Try to create a FollowList from an event, returns null if invalid + */ +export const tryToFollowList = (event: Event | null | undefined): FollowList | null => { + if (!event) return null + try { + return FollowList.fromEvent(event) + } catch { + return null + } +} + +/** + * Convert a FollowList to a legacy hex string set + */ +export const fromFollowListToHexSet = (followList: FollowList): Set<string> => { + return new Set(followList.getFollowing().map((p) => p.hex)) +} + +/** + * Convert a FollowList to a legacy hex string array + */ +export const fromFollowListToHexArray = (followList: FollowList): string[] => { + return followList.getFollowing().map((p) => p.hex) +} + +/** + * Check if a hex pubkey is in a FollowList + */ +export const isFollowingHex = (followList: FollowList, hex: string): boolean => { + const pubkey = tryToPubkey(hex) + return pubkey ? followList.isFollowing(pubkey) : false +} + +/** + * Add a hex pubkey to a FollowList + * @returns true if added, false if already following or invalid + */ +export const followByHex = ( + followList: FollowList, + hex: string, + relayHint?: string, + petname?: string +): boolean => { + const pubkey = tryToPubkey(hex) + if (!pubkey) return false + try { + const change = followList.follow(pubkey, relayHint, petname) + return change.type === 'added' + } catch { + return false + } +} + +/** + * Remove a hex pubkey from a FollowList + * @returns true if removed, false if not following or invalid + */ +export const unfollowByHex = (followList: FollowList, hex: string): boolean => { + const pubkey = tryToPubkey(hex) + if (!pubkey) return false + const change = followList.unfollow(pubkey) + return change.type === 'removed' +} + +// ============================================================================ +// MuteList Adapters +// ============================================================================ + +/** + * Convert a Nostr event to a MuteList domain object + * + * @param event The mute list event + * @param decryptedPrivateTags The decrypted private tags (from NIP-04 content) + */ +export const toMuteList = ( + event: Event, + decryptedPrivateTags: string[][] = [] +): MuteList => { + return MuteList.fromEvent(event, decryptedPrivateTags) +} + +/** + * Try to create a MuteList from an event, returns null if invalid + */ +export const tryToMuteList = ( + event: Event | null | undefined, + decryptedPrivateTags: string[][] = [] +): MuteList | null => { + if (!event) return null + try { + return MuteList.fromEvent(event, decryptedPrivateTags) + } catch { + return null + } +} + +/** + * Convert a MuteList to a legacy hex string set (all mutes) + */ +export const fromMuteListToHexSet = (muteList: MuteList): Set<string> => { + return new Set(muteList.getAllMuted().map((p) => p.hex)) +} + +/** + * Convert a MuteList's public mutes to a legacy hex string set + */ +export const fromMuteListToPublicHexSet = (muteList: MuteList): Set<string> => { + return new Set(muteList.getPublicMuted().map((p) => p.hex)) +} + +/** + * Convert a MuteList's private mutes to a legacy hex string set + */ +export const fromMuteListToPrivateHexSet = (muteList: MuteList): Set<string> => { + return new Set(muteList.getPrivateMuted().map((p) => p.hex)) +} + +/** + * Check if a hex pubkey is muted + */ +export const isMutedHex = (muteList: MuteList, hex: string): boolean => { + const pubkey = tryToPubkey(hex) + return pubkey ? muteList.isMuted(pubkey) : false +} + +/** + * Get the mute visibility for a hex pubkey + */ +export const getMuteVisibilityByHex = ( + muteList: MuteList, + hex: string +): MuteVisibility | null => { + const pubkey = tryToPubkey(hex) + return pubkey ? muteList.getMuteVisibility(pubkey) : null +} + +/** + * Mute a hex pubkey publicly + * @returns true if muted, false if already muted or invalid + */ +export const mutePubliclyByHex = (muteList: MuteList, hex: string): boolean => { + const pubkey = tryToPubkey(hex) + if (!pubkey) return false + try { + const change = muteList.mutePublicly(pubkey) + return change.type !== 'no_change' + } catch { + return false + } +} + +/** + * Mute a hex pubkey privately + * @returns true if muted, false if already muted or invalid + */ +export const mutePrivatelyByHex = (muteList: MuteList, hex: string): boolean => { + const pubkey = tryToPubkey(hex) + if (!pubkey) return false + try { + const change = muteList.mutePrivately(pubkey) + return change.type !== 'no_change' + } catch { + return false + } +} + +/** + * Unmute a hex pubkey + * @returns true if unmuted, false if not muted or invalid + */ +export const unmuteByHex = (muteList: MuteList, hex: string): boolean => { + const pubkey = tryToPubkey(hex) + if (!pubkey) return false + const change = muteList.unmute(pubkey) + return change.type === 'unmuted' +} + +// ============================================================================ +// Combined Adapters +// ============================================================================ + +/** + * Create a function to check if a pubkey should be filtered out + * (either muted or not following, depending on context) + */ +export const createMuteFilter = ( + muteList: MuteList +): ((hex: string) => boolean) => { + const mutedSet = fromMuteListToHexSet(muteList) + return (hex: string) => mutedSet.has(hex) +} + +/** + * Create a function to check if a pubkey is being followed + */ +export const createFollowFilter = ( + followList: FollowList +): ((hex: string) => boolean) => { + const followingSet = fromFollowListToHexSet(followList) + return (hex: string) => followingSet.has(hex) +} diff --git a/src/domain/social/errors.ts b/src/domain/social/errors.ts new file mode 100644 index 00000000..fff84acb --- /dev/null +++ b/src/domain/social/errors.ts @@ -0,0 +1,50 @@ +/** + * Domain errors for Social bounded context + */ + +import { DomainError } from '../shared' + +/** + * Thrown when attempting to follow oneself + */ +export class CannotFollowSelfError extends DomainError { + constructor() { + super('Cannot follow yourself') + } +} + +/** + * Thrown when attempting to mute oneself + */ +export class CannotMuteSelfError extends DomainError { + constructor() { + super('Cannot mute yourself') + } +} + +/** + * Thrown when attempting to perform an operation that requires authentication + */ +export class NotAuthenticatedError extends DomainError { + constructor() { + super('Authentication required for this operation') + } +} + +/** + * Thrown when a follow list operation fails + */ +export class FollowListOperationError extends DomainError { + constructor(operation: string, reason?: string) { + super(`Follow list operation failed: ${operation}${reason ? ` - ${reason}` : ''}`) + } +} + +/** + * Thrown when a mute list operation fails + */ +export class MuteListOperationError extends DomainError { + constructor(operation: string, reason?: string) { + super(`Mute list operation failed: ${operation}${reason ? ` - ${reason}` : ''}`) + } +} diff --git a/src/domain/social/events.ts b/src/domain/social/events.ts new file mode 100644 index 00000000..b8850728 --- /dev/null +++ b/src/domain/social/events.ts @@ -0,0 +1,143 @@ +import { Pubkey, Timestamp } from '../shared' +import { MuteVisibility } from './MuteList' + +/** + * Base class for all domain events + */ +export abstract class DomainEvent { + readonly occurredAt: Timestamp + + constructor() { + this.occurredAt = Timestamp.now() + } + + abstract get eventType(): string +} + +// ============================================================================ +// Follow List Events +// ============================================================================ + +/** + * Raised when a user follows another user + */ +export class UserFollowed extends DomainEvent { + readonly eventType = 'social.user_followed' + + constructor( + readonly actor: Pubkey, + readonly followed: Pubkey, + readonly relayHint?: string, + readonly petname?: string + ) { + super() + } +} + +/** + * Raised when a user unfollows another user + */ +export class UserUnfollowed extends DomainEvent { + readonly eventType = 'social.user_unfollowed' + + constructor( + readonly actor: Pubkey, + readonly unfollowed: Pubkey + ) { + super() + } +} + +/** + * Raised when a follow list is published + */ +export class FollowListPublished extends DomainEvent { + readonly eventType = 'social.follow_list_published' + + constructor( + readonly owner: Pubkey, + readonly followingCount: number + ) { + super() + } +} + +// ============================================================================ +// Mute List Events +// ============================================================================ + +/** + * Raised when a user mutes another user + */ +export class UserMuted extends DomainEvent { + readonly eventType = 'social.user_muted' + + constructor( + readonly actor: Pubkey, + readonly muted: Pubkey, + readonly visibility: MuteVisibility + ) { + super() + } +} + +/** + * Raised when a user unmutes another user + */ +export class UserUnmuted extends DomainEvent { + readonly eventType = 'social.user_unmuted' + + constructor( + readonly actor: Pubkey, + readonly unmuted: Pubkey + ) { + super() + } +} + +/** + * Raised when mute visibility is changed (public to private or vice versa) + */ +export class MuteVisibilityChanged extends DomainEvent { + readonly eventType = 'social.mute_visibility_changed' + + constructor( + readonly actor: Pubkey, + readonly target: Pubkey, + readonly from: MuteVisibility, + readonly to: MuteVisibility + ) { + super() + } +} + +/** + * Raised when a mute list is published + */ +export class MuteListPublished extends DomainEvent { + readonly eventType = 'social.mute_list_published' + + constructor( + readonly owner: Pubkey, + readonly publicMuteCount: number, + readonly privateMuteCount: number + ) { + super() + } +} + +// ============================================================================ +// Event Types Union +// ============================================================================ + +/** + * Union type of all social domain events + */ +export type SocialDomainEvent = + | UserFollowed + | UserUnfollowed + | FollowListPublished + | UserMuted + | UserUnmuted + | MuteVisibilityChanged + | MuteListPublished diff --git a/src/domain/social/index.ts b/src/domain/social/index.ts new file mode 100644 index 00000000..ff1bc4b8 --- /dev/null +++ b/src/domain/social/index.ts @@ -0,0 +1,63 @@ +/** + * Social Bounded Context + * + * Handles following, muting, and other social graph relationships. + */ + +// Aggregates +export { FollowList } from './FollowList' +export type { FollowEntry, FollowListChange } from './FollowList' + +export { MuteList } from './MuteList' +export type { MuteEntry, MuteVisibility, MuteListChange } from './MuteList' + +// Domain Events +export { + DomainEvent, + UserFollowed, + UserUnfollowed, + FollowListPublished, + UserMuted, + UserUnmuted, + MuteVisibilityChanged, + MuteListPublished +} from './events' +export type { SocialDomainEvent } from './events' + +// Errors +export { + CannotFollowSelfError, + CannotMuteSelfError, + NotAuthenticatedError, + FollowListOperationError, + MuteListOperationError +} from './errors' + +// Repository Interfaces +export type { FollowListRepository, MuteListRepository } from './repositories' + +// Adapters for migration +export { + // FollowList adapters + toFollowList, + tryToFollowList, + fromFollowListToHexSet, + fromFollowListToHexArray, + isFollowingHex, + followByHex, + unfollowByHex, + // MuteList adapters + toMuteList, + tryToMuteList, + fromMuteListToHexSet, + fromMuteListToPublicHexSet, + fromMuteListToPrivateHexSet, + isMutedHex, + getMuteVisibilityByHex, + mutePubliclyByHex, + mutePrivatelyByHex, + unmuteByHex, + // Combined adapters + createMuteFilter, + createFollowFilter +} from './adapters' diff --git a/src/domain/social/repositories.ts b/src/domain/social/repositories.ts new file mode 100644 index 00000000..59ac612f --- /dev/null +++ b/src/domain/social/repositories.ts @@ -0,0 +1,49 @@ +import { Pubkey } from '../shared' +import { FollowList } from './FollowList' +import { MuteList } from './MuteList' + +/** + * Repository interface for FollowList aggregate + * + * Implementations should handle: + * - Local caching (IndexedDB) + * - Remote fetching from relays + * - Event publishing + */ +export interface FollowListRepository { + /** + * Find the follow list for a user + * Should check cache first, then fetch from relays if not found + */ + findByOwner(pubkey: Pubkey): Promise<FollowList | null> + + /** + * Save a follow list + * Should publish to relays and update local cache + */ + save(followList: FollowList): Promise<void> +} + +/** + * Repository interface for MuteList aggregate + * + * Implementations should handle: + * - Local caching (IndexedDB) + * - Remote fetching from relays + * - NIP-04 encryption/decryption for private mutes + * - Event publishing + */ +export interface MuteListRepository { + /** + * Find the mute list for a user + * Should check cache first, then fetch from relays if not found + * Private mutes should be decrypted automatically + */ + findByOwner(pubkey: Pubkey): Promise<MuteList | null> + + /** + * Save a mute list + * Should encrypt private mutes and publish to relays + */ + save(muteList: MuteList): Promise<void> +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4c3c70d4..263b05da 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -90,6 +90,9 @@ export default { 'Login with Browser Extension': 'Login with Browser Extension', 'Login with Bunker': 'Login with Bunker', 'Login with Private Key': 'Login with Private Key', + 'Custom relay (optional)': 'Custom relay (optional)', + 'Copy this connection string to your signer app': + 'Copy this connection string to your signer app', 'reload notes': 'reload notes', 'Logged in Accounts': 'Logged in Accounts', 'Add an Account': 'Add an Account', diff --git a/src/index.css b/src/index.css index 802e8f1f..b991e4a0 100644 --- a/src/index.css +++ b/src/index.css @@ -105,6 +105,7 @@ /* Light theme variables */ :root, .light { + --chrome-background: 0 0% 100%; --surface-background: 0 0% 98%; --background: 0 0% 100%; --foreground: 240 10% 3.9%; @@ -136,6 +137,7 @@ /* System dark preference - apply dark theme by default */ @media (prefers-color-scheme: dark) { :root:not(.light) { + --chrome-background: 0 0% 0%; --surface-background: 240 10% 3.9%; --background: 0 0% 9%; --foreground: 0 0% 98%; @@ -167,6 +169,7 @@ } /* Explicit dark class override */ .dark { + --chrome-background: 0 0% 0%; --surface-background: 240 10% 3.9%; --background: 0 0% 9%; --foreground: 0 0% 98%; diff --git a/src/layouts/PrimaryPageLayout/index.tsx b/src/layouts/PrimaryPageLayout/index.tsx index 678b3733..59a37491 100644 --- a/src/layouts/PrimaryPageLayout/index.tsx +++ b/src/layouts/PrimaryPageLayout/index.tsx @@ -80,7 +80,7 @@ const PrimaryPageLayout = forwardRef( <div ref={smallScreenScrollAreaRef} style={{ - paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)' + paddingBottom: 'env(safe-area-inset-bottom)' }} > <PrimaryPageTitlebar hideBottomBorder={hideTitlebarBottomBorder}> diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index 3bb95414..2178bff3 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -63,7 +63,7 @@ const SecondaryPageLayout = forwardRef( <DeepBrowsingProvider active={currentIndex === index}> <div style={{ - paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)' + paddingBottom: 'env(safe-area-inset-bottom)' }} > <SecondaryPageTitlebar diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 9759c683..3e046078 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -8,7 +8,8 @@ import { TMailboxRelay, TMailboxRelayScope, TPollCreateData, - TRelaySet + TRelaySet, + TSyncSettings } from '@/types' import { sha256 } from '@noble/hashes/sha2' import dayjs from 'dayjs' @@ -443,6 +444,15 @@ export function createSeenNotificationsAtDraftEvent(): TDraftEvent { } } +export function createSettingsDraftEvent(settings: TSyncSettings): TDraftEvent { + return { + kind: kinds.Application, + content: JSON.stringify(settings), + tags: [buildDTag(ApplicationDataKey.SETTINGS)], + created_at: dayjs().unix() + } +} + export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraftEvent { return { kind: kinds.BookmarkList, diff --git a/src/lib/link.ts b/src/lib/link.ts index 2a86b715..191a701d 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -90,3 +90,5 @@ export const toUserAggregationDetail = (feedId: string, pubkey: string) => { const npub = nip19.npubEncode(pubkey) return `/user-aggregation/${feedId}/${npub}` } +export const toLogin = () => '/login' +export const toLogout = () => '/logout' diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx deleted file mode 100644 index a43dffa3..00000000 --- a/src/pages/primary/ExplorePage/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import Explore from '@/components/Explore' -import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList' -import NoteList from '@/components/NoteList' -import Tabs from '@/components/Tabs' -import { Button } from '@/components/ui/button' -import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' -import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' -import { getReplaceableEventIdentifier } from '@/lib/event' -import { isLocalNetworkUrl, isOnionUrl, isWebsocketUrl } from '@/lib/url' -import { useUserTrust } from '@/providers/UserTrustProvider' -import storage from '@/services/local-storage.service' -import { TPageRef } from '@/types' -import { Compass, Plus } from 'lucide-react' -import { NostrEvent } from 'nostr-tools' -import { forwardRef, useCallback, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' - -type TExploreTabs = 'following' | 'explore' | 'reviews' - -const ExplorePage = forwardRef<TPageRef>((_, ref) => { - const { hideUntrustedNotes } = useUserTrust() - const [tab, setTab] = useState<TExploreTabs>('explore') - const topRef = useRef<HTMLDivElement | null>(null) - - const relayReviewFilterFn = useCallback((evt: NostrEvent) => { - const d = getReplaceableEventIdentifier(evt) - if (!d) return false - - if (!isWebsocketUrl(d)) { - return false - } - if (isLocalNetworkUrl(d)) { - return false - } - if (storage.getFilterOutOnionRelays() && isOnionUrl(d)) { - return false - } - return true - }, []) - - const content = useMemo(() => { - return tab === 'explore' ? ( - <Explore /> - ) : tab === 'reviews' ? ( - <NoteList - showKinds={[ExtendedKind.RELAY_REVIEW]} - subRequests={[{ urls: BIG_RELAY_URLS, filter: {} }]} - filterFn={relayReviewFilterFn} - filterMutedNotes - hideSpam - /> - ) : ( - <FollowingFavoriteRelayList /> - ) - }, [tab, relayReviewFilterFn, hideUntrustedNotes]) - - return ( - <PrimaryPageLayout - ref={ref} - pageName="explore" - titlebar={<ExplorePageTitlebar />} - displayScrollToTopButton - > - <Tabs - value={tab} - tabs={[ - { value: 'explore', label: 'Explore' }, - { value: 'reviews', label: 'Reviews' }, - { value: 'following', label: "Following's Favorites" } - ]} - onTabChange={(tab) => { - setTab(tab as TExploreTabs) - topRef.current?.scrollIntoView({ behavior: 'instant' }) - }} - /> - <div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" /> - {content} - </PrimaryPageLayout> - ) -}) -ExplorePage.displayName = 'ExplorePage' -export default ExplorePage - -function ExplorePageTitlebar() { - const { t } = useTranslation() - - return ( - <div className="flex gap-2 justify-between h-full"> - <div className="flex gap-2 items-center h-full pl-3"> - <Compass /> - <div className="text-lg font-semibold">{t('Explore')}</div> - </div> - <Button - variant="ghost" - size="titlebar-icon" - className="relative w-fit px-3" - onClick={() => { - window.open( - 'https://github.com/CodyTseng/awesome-nostr-relays/issues/new?template=add-relay.md', - '_blank' - ) - }} - > - <Plus size={16} /> - {t('Submit Relay')} - </Button> - </div> - ) -} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index ce0c933b..44bd6cda 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -1,15 +1,12 @@ -import { usePrimaryPage, useSecondaryPage } from '@/PageManager' -import PostEditor from '@/components/PostEditor' +import { usePrimaryPage } from '@/PageManager' import RelayInfo from '@/components/RelayInfo' import { Button } from '@/components/ui/button' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' -import { toSearch } from '@/lib/link' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFeed } from '@/providers/FeedProvider' import { useNostr } from '@/providers/NostrProvider' -import { useScreenSize } from '@/providers/ScreenSizeProvider' import { TPageRef } from '@/types' -import { Compass, Info, LogIn, PencilLine, Search, Sparkles } from 'lucide-react' +import { Compass, Info, LogIn, Sparkles } from 'lucide-react' import { Dispatch, forwardRef, @@ -109,8 +106,6 @@ function NoteListPageTitlebar({ showRelayDetails?: boolean setShowRelayDetails?: Dispatch<SetStateAction<boolean>> }) { - const { isSmallScreen } = useScreenSize() - return ( <div className="flex gap-1 items-center h-full justify-between"> <FeedButton className="flex-1 max-w-fit w-0" /> @@ -132,50 +127,11 @@ function NoteListPageTitlebar({ <Info /> </Button> )} - {isSmallScreen && ( - <> - <SearchButton /> - <PostButton /> - </> - )} </div> </div> ) } -function PostButton() { - const { checkLogin } = useNostr() - const [open, setOpen] = useState(false) - - return ( - <> - <Button - variant="ghost" - size="titlebar-icon" - onClick={(e) => { - e.stopPropagation() - checkLogin(() => { - setOpen(true) - }) - }} - > - <PencilLine /> - </Button> - <PostEditor open={open} setOpen={setOpen} /> - </> - ) -} - -function SearchButton() { - const { push } = useSecondaryPage() - - return ( - <Button variant="ghost" size="titlebar-icon" onClick={() => push(toSearch())}> - <Search /> - </Button> - ) -} - function WelcomeGuide() { const { t } = useTranslation() const { navigate } = usePrimaryPage() diff --git a/src/pages/primary/ProfilePage/index.tsx b/src/pages/primary/ProfilePage/index.tsx index d2dbb465..2aac4399 100644 --- a/src/pages/primary/ProfilePage/index.tsx +++ b/src/pages/primary/ProfilePage/index.tsx @@ -1,9 +1,11 @@ +import LoginDialog from '@/components/LoginDialog' import Profile from '@/components/Profile' +import { Button } from '@/components/ui/button' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { useNostr } from '@/providers/NostrProvider' import { TPageRef } from '@/types' -import { UserRound } from 'lucide-react' -import { forwardRef } from 'react' +import { ArrowDownUp, UserRound } from 'lucide-react' +import { forwardRef, useState } from 'react' import { useTranslation } from 'react-i18next' const ProfilePage = forwardRef<TPageRef>((_, ref) => { @@ -25,11 +27,20 @@ export default ProfilePage function ProfilePageTitlebar() { const { t } = useTranslation() + const [loginDialogOpen, setLoginDialogOpen] = useState(false) return ( - <div className="flex gap-2 items-center h-full pl-3"> - <UserRound /> - <div className="text-lg font-semibold">{t('Profile')}</div> - </div> + <> + <div className="flex justify-between items-center h-full w-full pl-3 pr-1"> + <div className="flex gap-2 items-center"> + <UserRound /> + <div className="text-lg font-semibold">{t('Profile')}</div> + </div> + <Button variant="ghost" size="titlebar-icon" onClick={() => setLoginDialogOpen(true)}> + <ArrowDownUp /> + </Button> + </div> + <LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} /> + </> ) } diff --git a/src/pages/secondary/LoginPage/index.tsx b/src/pages/secondary/LoginPage/index.tsx new file mode 100644 index 00000000..58a81164 --- /dev/null +++ b/src/pages/secondary/LoginPage/index.tsx @@ -0,0 +1,20 @@ +import AccountManager from '@/components/AccountManager' +import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { useSecondaryPage } from '@/PageManager' +import { forwardRef } from 'react' +import { useTranslation } from 'react-i18next' + +const LoginPage = forwardRef(({ index }: { index?: number }, ref) => { + const { t } = useTranslation() + const { pop } = useSecondaryPage() + + return ( + <SecondaryPageLayout ref={ref} index={index} title={t('Login')}> + <div className="p-4"> + <AccountManager close={() => pop()} /> + </div> + </SecondaryPageLayout> + ) +}) +LoginPage.displayName = 'LoginPage' +export default LoginPage diff --git a/src/pages/secondary/LogoutPage/index.tsx b/src/pages/secondary/LogoutPage/index.tsx new file mode 100644 index 00000000..74cdf73e --- /dev/null +++ b/src/pages/secondary/LogoutPage/index.tsx @@ -0,0 +1,37 @@ +import { Button } from '@/components/ui/button' +import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { useSecondaryPage } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' +import { forwardRef } from 'react' +import { useTranslation } from 'react-i18next' + +const LogoutPage = forwardRef(({ index }: { index?: number }, ref) => { + const { t } = useTranslation() + const { pop } = useSecondaryPage() + const { account, removeAccount } = useNostr() + + const handleLogout = () => { + if (account) { + removeAccount(account) + pop() + } + } + + return ( + <SecondaryPageLayout ref={ref} index={index} title={t('Logout')}> + <div className="p-4 space-y-6"> + <p className="text-muted-foreground">{t('Are you sure you want to logout?')}</p> + <div className="flex flex-col gap-3"> + <Button variant="outline" onClick={() => pop()} className="w-full"> + {t('Cancel')} + </Button> + <Button variant="destructive" onClick={handleLogout} className="w-full"> + {t('Logout')} + </Button> + </div> + </div> + </SecondaryPageLayout> + ) +}) +LogoutPage.displayName = 'LogoutPage' +export default LogoutPage diff --git a/src/pages/secondary/SystemSettingsPage/index.tsx b/src/pages/secondary/SystemSettingsPage/index.tsx index 8a949c54..a0cc6652 100644 --- a/src/pages/secondary/SystemSettingsPage/index.tsx +++ b/src/pages/secondary/SystemSettingsPage/index.tsx @@ -4,7 +4,7 @@ import { Switch } from '@/components/ui/switch' import { DEFAULT_FAVICON_URL_TEMPLATE } from '@/constants' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import storage from '@/services/local-storage.service' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { forwardRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -40,6 +40,7 @@ const SystemSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { onCheckedChange={(checked) => { storage.setFilterOutOnionRelays(checked) setFilterOutOnionRelays(checked) + dispatchSettingsChanged() }} /> </div> diff --git a/src/providers/ContentPolicyProvider.tsx b/src/providers/ContentPolicyProvider.tsx index 63ab2c7f..11ad563e 100644 --- a/src/providers/ContentPolicyProvider.tsx +++ b/src/providers/ContentPolicyProvider.tsx @@ -1,5 +1,5 @@ import { MEDIA_AUTO_LOAD_POLICY } from '@/constants' -import storage from '@/services/local-storage.service' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types' import { createContext, useContext, useEffect, useMemo, useState } from 'react' @@ -70,26 +70,31 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode const updateAutoplay = (autoplay: boolean) => { storage.setAutoplay(autoplay) setAutoplay(autoplay) + dispatchSettingsChanged() } const updateNsfwDisplayPolicy = (policy: TNsfwDisplayPolicy) => { storage.setNsfwDisplayPolicy(policy) setNsfwDisplayPolicy(policy) + dispatchSettingsChanged() } const updateHideContentMentioningMutedUsers = (hide: boolean) => { storage.setHideContentMentioningMutedUsers(hide) setHideContentMentioningMutedUsers(hide) + dispatchSettingsChanged() } const updateMediaAutoLoadPolicy = (policy: TMediaAutoLoadPolicy) => { storage.setMediaAutoLoadPolicy(policy) setMediaAutoLoadPolicy(policy) + dispatchSettingsChanged() } const updateFaviconUrlTemplate = (template: string) => { storage.setFaviconUrlTemplate(template) setFaviconUrlTemplate(template) + dispatchSettingsChanged() } return ( diff --git a/src/providers/KindFilterProvider.tsx b/src/providers/KindFilterProvider.tsx index 09867a40..a9a1e9c5 100644 --- a/src/providers/KindFilterProvider.tsx +++ b/src/providers/KindFilterProvider.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useState } from 'react' -import storage from '@/services/local-storage.service' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' type TKindFilterContext = { showKinds: number[] @@ -22,6 +22,7 @@ export function KindFilterProvider({ children }: { children: React.ReactNode }) const updateShowKinds = (kinds: number[]) => { storage.setShowKinds(kinds) setShowKinds(kinds) + dispatchSettingsChanged() } return ( diff --git a/src/providers/NostrProvider/bunker.signer.ts b/src/providers/NostrProvider/bunker.signer.ts deleted file mode 100644 index 47e09cfa..00000000 --- a/src/providers/NostrProvider/bunker.signer.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ISigner, TDraftEvent } from '@/types' -import { bytesToHex, hexToBytes } from '@noble/hashes/utils' -import { generateSecretKey } from 'nostr-tools' -import { BunkerSigner as NBunkerSigner, parseBunkerInput } from 'nostr-tools/nip46' - -export class BunkerSigner implements ISigner { - signer: NBunkerSigner | null = null - private clientSecretKey: Uint8Array - private pubkey: string | null = null - - constructor(clientSecretKey?: string) { - this.clientSecretKey = clientSecretKey ? hexToBytes(clientSecretKey) : generateSecretKey() - } - - async login(bunker: string, isInitialConnection = true): Promise<string> { - const bunkerPointer = await parseBunkerInput(bunker) - if (!bunkerPointer) { - throw new Error('Invalid bunker') - } - - this.signer = NBunkerSigner.fromBunker(this.clientSecretKey, bunkerPointer, { - onauth: (url) => { - window.open(url, '_blank') - } - }) - if (isInitialConnection) { - await this.signer.connect() - } - return await this.signer.getPublicKey() - } - - async getPublicKey() { - if (!this.signer) { - throw new Error('Not logged in') - } - if (!this.pubkey) { - this.pubkey = await this.signer.getPublicKey() - } - return this.pubkey - } - - async signEvent(draftEvent: TDraftEvent) { - if (!this.signer) { - throw new Error('Not logged in') - } - return this.signer.signEvent(draftEvent) - } - - async nip04Encrypt(pubkey: string, plainText: string) { - if (!this.signer) { - throw new Error('Not logged in') - } - return await this.signer.nip04Encrypt(pubkey, plainText) - } - - async nip04Decrypt(pubkey: string, cipherText: string) { - if (!this.signer) { - throw new Error('Not logged in') - } - return await this.signer.nip04Decrypt(pubkey, cipherText) - } - - getClientSecretKey() { - return bytesToHex(this.clientSecretKey) - } -} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 2fc200c7..91d5a393 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -29,18 +29,17 @@ import { TPublishOptions, TRelayList } from '@/types' -import { hexToBytes } from '@noble/hashes/utils' +import * as nobleUtils from '@noble/curves/abstract/utils' +import { bech32 } from '@scure/base' import dayjs from 'dayjs' import { Event, kinds, VerifiedEvent } from 'nostr-tools' -import * as nip19 from 'nostr-tools/nip19' import * as nip49 from 'nostr-tools/nip49' import { createContext, useContext, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useDeletedEvent } from '../DeletedEventProvider' -import { BunkerSigner } from './bunker.signer' +import { usePasswordPrompt } from '../PasswordPromptProvider' import { Nip07Signer } from './nip-07.signer' -import { NostrConnectionSigner } from './nostrConnection.signer' import { NpubSigner } from './npub.signer' import { NsecSigner } from './nsec.signer' @@ -66,8 +65,6 @@ type TNostrContext = { nsecLogin: (nsec: string, password?: string, needSetup?: boolean) => Promise<string> ncryptsecLogin: (ncryptsec: string) => Promise<string> nip07Login: () => Promise<string> - bunkerLogin: (bunker: string) => Promise<string> - nostrConnectionLogin: (clientSecretKey: Uint8Array, connectionString: string) => Promise<string> npubLogin(npub: string): Promise<string> removeAccount: (account: TAccountPointer) => void /** @@ -108,6 +105,7 @@ export const useNostr = () => { export function NostrProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation() const { addDeletedEvent } = useDeletedEvent() + const { promptPassword } = usePasswordPrompt() const [accounts, setAccounts] = useState<TAccountPointer[]>( storage.getAccounts().map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })) ) @@ -420,9 +418,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const urlWithoutHash = window.location.href.split('#')[0] history.replaceState(null, '', urlWithoutHash) - if (credential.startsWith('bunker://')) { - return await bunkerLogin(credential) - } else if (credential.startsWith('ncryptsec')) { + if (credential.startsWith('ncryptsec')) { return await ncryptsecLogin(credential) } else if (credential.startsWith('nsec')) { return await nsecLogin(credential) @@ -460,23 +456,34 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const nsecLogin = async (nsecOrHex: string, password?: string, needSetup?: boolean) => { const nsecSigner = new NsecSigner() let privkey: Uint8Array - if (nsecOrHex.startsWith('nsec')) { - const { type, data } = nip19.decode(nsecOrHex) - if (type !== 'nsec') { - throw new Error('invalid nsec or hex') + const input = nsecOrHex.trim() + + if (input.startsWith('nsec')) { + // Use @scure/base bech32 for robust decoding (same as plebeian-signer) + try { + const { prefix, words } = bech32.decode(input as `${string}1${string}`, 5000) + if (prefix !== 'nsec') { + throw new Error('invalid nsec prefix') + } + privkey = new Uint8Array(bech32.fromWords(words)) + } catch (err) { + throw new Error(`invalid nsec: ${err instanceof Error ? err.message : 'decode failed'}`) } - privkey = data - } else if (/^[0-9a-fA-F]{64}$/.test(nsecOrHex)) { - privkey = hexToBytes(nsecOrHex) + } else if (/^[0-9a-fA-F]{64}$/.test(input)) { + privkey = nobleUtils.hexToBytes(input) } else { throw new Error('invalid nsec or hex') } + const pubkey = nsecSigner.login(privkey) if (password) { const ncryptsec = nip49.encrypt(privkey, password) login(nsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec }) } else { - login(nsecSigner, { pubkey, signerType: 'nsec', nsec: nip19.nsecEncode(privkey) }) + // Use bech32 encode for consistency + const words = bech32.toWords(privkey) + const nsec = bech32.encode('nsec', words, 5000) + login(nsecSigner, { pubkey, signerType: 'nsec', nsec }) } if (needSetup) { setupNewUser(nsecSigner) @@ -485,7 +492,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } const ncryptsecLogin = async (ncryptsec: string) => { - const password = prompt(t('Enter the password to decrypt your ncryptsec')) + const password = await promptPassword(t('Enter the password to decrypt your ncryptsec')) if (!password) { throw new Error('Password is required') } @@ -516,38 +523,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } - const bunkerLogin = async (bunker: string) => { - const bunkerSigner = new BunkerSigner() - const pubkey = await bunkerSigner.login(bunker) - if (!pubkey) { - throw new Error('Invalid bunker') - } - const bunkerUrl = new URL(bunker) - bunkerUrl.searchParams.delete('secret') - return login(bunkerSigner, { - pubkey, - signerType: 'bunker', - bunker: bunkerUrl.toString(), - bunkerClientSecretKey: bunkerSigner.getClientSecretKey() - }) - } - - const nostrConnectionLogin = async (clientSecretKey: Uint8Array, connectionString: string) => { - const bunkerSigner = new NostrConnectionSigner(clientSecretKey, connectionString) - const loginResult = await bunkerSigner.login() - if (!loginResult.pubkey) { - throw new Error('Invalid bunker') - } - const bunkerUrl = new URL(loginResult.bunkerString!) - bunkerUrl.searchParams.delete('secret') - return login(bunkerSigner, { - pubkey: loginResult.pubkey, - signerType: 'bunker', - bunker: bunkerUrl.toString(), - bunkerClientSecretKey: bunkerSigner.getClientSecretKey() - }) - } - const loginWithAccountPointer = async (act: TAccountPointer): Promise<string | null> => { let account = storage.findAccount(act) if (!account) { @@ -567,7 +542,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } else if (account.signerType === 'ncryptsec') { if (account.ncryptsec) { - const password = prompt(t('Enter the password to decrypt your ncryptsec')) + const password = await promptPassword(t('Enter the password to decrypt your ncryptsec')) if (!password) { return null } @@ -580,21 +555,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const nip07Signer = new Nip07Signer() await nip07Signer.init() return login(nip07Signer, account) - } else if (account.signerType === 'bunker') { - if (account.bunker && account.bunkerClientSecretKey) { - const bunkerSigner = new BunkerSigner(account.bunkerClientSecretKey) - const pubkey = await bunkerSigner.login(account.bunker, false) - if (!pubkey) { - storage.removeAccount(account) - return null - } - if (pubkey !== account.pubkey) { - storage.removeAccount(account) - account = { ...account, pubkey } - storage.addAccount(account) - } - return login(bunkerSigner, account) - } } else if (account.signerType === 'npub' && account.npub) { const npubSigner = new NpubSigner() const pubkey = npubSigner.login(account.npub) @@ -831,8 +791,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { nsecLogin, ncryptsecLogin, nip07Login, - bunkerLogin, - nostrConnectionLogin, npubLogin, removeAccount, publish, diff --git a/src/providers/NostrProvider/nostrConnection.signer.ts b/src/providers/NostrProvider/nostrConnection.signer.ts deleted file mode 100644 index 3c79de6e..00000000 --- a/src/providers/NostrProvider/nostrConnection.signer.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ISigner, TDraftEvent } from '@/types' -import { bytesToHex } from '@noble/hashes/utils' -import { BunkerSigner as NBunkerSigner, toBunkerURL } from 'nostr-tools/nip46' - -export class NostrConnectionSigner implements ISigner { - signer: NBunkerSigner | null = null - private clientSecretKey: Uint8Array - private pubkey: string | null = null - private connectionString: string - private bunkerString: string | null = null - - constructor(clientSecretKey: Uint8Array, connectionString: string) { - this.clientSecretKey = clientSecretKey - this.connectionString = connectionString - } - - async login() { - if (this.pubkey) { - return { - bunkerString: this.bunkerString, - pubkey: this.pubkey - } - } - - this.signer = await NBunkerSigner.fromURI(this.clientSecretKey, this.connectionString, { - onauth: (url) => { - window.open(url, '_blank') - } - }) - this.bunkerString = toBunkerURL(this.signer.bp) - this.pubkey = await this.signer.getPublicKey() - return { - bunkerString: this.bunkerString, - pubkey: this.pubkey - } - } - - async getPublicKey() { - if (!this.signer) { - throw new Error('Not logged in') - } - if (!this.pubkey) { - this.pubkey = await this.signer.getPublicKey() - } - return this.pubkey - } - - async signEvent(draftEvent: TDraftEvent) { - if (!this.signer) { - throw new Error('Not logged in') - } - return this.signer.signEvent(draftEvent) - } - - async nip04Encrypt(pubkey: string, plainText: string) { - if (!this.signer) { - throw new Error('Not logged in') - } - return await this.signer.nip04Encrypt(pubkey, plainText) - } - - async nip04Decrypt(pubkey: string, cipherText: string) { - if (!this.signer) { - throw new Error('Not logged in') - } - return await this.signer.nip04Decrypt(pubkey, cipherText) - } - - getClientSecretKey() { - return bytesToHex(this.clientSecretKey) - } -} diff --git a/src/providers/NostrProvider/nsec.signer.ts b/src/providers/NostrProvider/nsec.signer.ts index 138575a6..ce09036e 100644 --- a/src/providers/NostrProvider/nsec.signer.ts +++ b/src/providers/NostrProvider/nsec.signer.ts @@ -1,18 +1,57 @@ import { ISigner, TDraftEvent } from '@/types' -import { finalizeEvent, getPublicKey as nGetPublicKey, nip04, nip19 } from 'nostr-tools' +import * as utils from '@noble/curves/abstract/utils' +import { bech32 } from '@scure/base' +import { finalizeEvent, getPublicKey as nGetPublicKey, nip04 } from 'nostr-tools' + +/** + * Convert nsec (bech32) to hex string + */ +function nsecToHex(nsec: string): string { + const { prefix, words } = bech32.decode(nsec as `${string}1${string}`, 5000) + if (prefix !== 'nsec') { + throw new Error('Invalid nsec prefix') + } + const data = new Uint8Array(bech32.fromWords(words)) + return utils.bytesToHex(data) +} + +/** + * Normalize a private key to hex format. + * Accepts nsec (bech32) or hex string. + */ +function normalizeToHex(privateKey: string): string { + if (privateKey.startsWith('nsec')) { + return nsecToHex(privateKey) + } + return privateKey +} + +/** + * Validate that a hex key is exactly 64 characters. + */ +function validateHexKey(hex: string): void { + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error('Private key must be 64 hex characters') + } +} export class NsecSigner implements ISigner { private privkey: Uint8Array | null = null private pubkey: string | null = null login(nsecOrPrivkey: string | Uint8Array) { - let privkey + let privkey: Uint8Array + if (typeof nsecOrPrivkey === 'string') { - const { type, data } = nip19.decode(nsecOrPrivkey) - if (type !== 'nsec') { - throw new Error('invalid nsec') + try { + const hex = normalizeToHex(nsecOrPrivkey) + validateHexKey(hex) + privkey = utils.hexToBytes(hex) + } catch (error) { + throw new Error( + `Invalid private key: ${error instanceof Error ? error.message : 'unknown error'}` + ) } - privkey = data } else { privkey = nsecOrPrivkey } diff --git a/src/providers/PasswordPromptProvider.tsx b/src/providers/PasswordPromptProvider.tsx new file mode 100644 index 00000000..294b0727 --- /dev/null +++ b/src/providers/PasswordPromptProvider.tsx @@ -0,0 +1,87 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' +import { Input } from '@/components/ui/input' +import { createContext, useCallback, useContext, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type PasswordPromptContextType = { + promptPassword: (message: string) => Promise<string | null> +} + +const PasswordPromptContext = createContext<PasswordPromptContextType | undefined>(undefined) + +export const usePasswordPrompt = () => { + const context = useContext(PasswordPromptContext) + if (!context) { + throw new Error('usePasswordPrompt must be used within PasswordPromptProvider') + } + return context +} + +export function PasswordPromptProvider({ children }: { children: React.ReactNode }) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [message, setMessage] = useState('') + const [password, setPassword] = useState('') + const resolverRef = useRef<((value: string | null) => void) | null>(null) + + const promptPassword = useCallback((msg: string): Promise<string | null> => { + return new Promise((resolve) => { + setMessage(msg) + setPassword('') + setOpen(true) + resolverRef.current = resolve + }) + }, []) + + const handleConfirm = () => { + setOpen(false) + resolverRef.current?.(password) + resolverRef.current = null + } + + const handleCancel = () => { + setOpen(false) + resolverRef.current?.(null) + resolverRef.current = null + } + + return ( + <PasswordPromptContext.Provider value={{ promptPassword }}> + {children} + <AlertDialog open={open} onOpenChange={(o) => !o && handleCancel()}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>{t('Password Required')}</AlertDialogTitle> + <AlertDialogDescription>{message}</AlertDialogDescription> + </AlertDialogHeader> + <Input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder={t('Enter password')} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleConfirm() + } + }} + /> + <AlertDialogFooter> + <AlertDialogCancel onClick={handleCancel}>{t('Cancel')}</AlertDialogCancel> + <AlertDialogAction onClick={handleConfirm}>{t('Confirm')}</AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </PasswordPromptContext.Provider> + ) +} diff --git a/src/providers/ScreenSizeProvider.tsx b/src/providers/ScreenSizeProvider.tsx index 3f86b59d..6c1883c2 100644 --- a/src/providers/ScreenSizeProvider.tsx +++ b/src/providers/ScreenSizeProvider.tsx @@ -1,8 +1,11 @@ -import { createContext, useContext, useMemo } from 'react' +import { createContext, useContext, useEffect, useState } from 'react' type TScreenSizeContext = { isSmallScreen: boolean + isTabletScreen: boolean + isNarrowDesktop: boolean isLargeScreen: boolean + canUseDoublePane: boolean } const ScreenSizeContext = createContext<TScreenSizeContext | undefined>(undefined) @@ -16,14 +19,38 @@ export const useScreenSize = () => { } export function ScreenSizeProvider({ children }: { children: React.ReactNode }) { - const isSmallScreen = useMemo(() => window.innerWidth <= 768, []) - const isLargeScreen = useMemo(() => window.innerWidth >= 1280, []) + const [isSmallScreen, setIsSmallScreen] = useState(() => window.innerWidth <= 768) + const [isTabletScreen, setIsTabletScreen] = useState( + () => window.innerWidth >= 480 && window.innerWidth <= 768 + ) + const [isNarrowDesktop, setIsNarrowDesktop] = useState( + () => window.innerWidth > 768 && window.innerWidth <= 1024 + ) + const [isLargeScreen, setIsLargeScreen] = useState(() => window.innerWidth >= 1280) + const [canUseDoublePane, setCanUseDoublePane] = useState(() => window.innerWidth > 1024) + + useEffect(() => { + const handleResize = () => { + const width = window.innerWidth + setIsSmallScreen(width <= 768) + setIsTabletScreen(width >= 480 && width <= 768) + setIsNarrowDesktop(width > 768 && width <= 1024) + setIsLargeScreen(width >= 1280) + setCanUseDoublePane(width > 1024) + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) return ( <ScreenSizeContext.Provider value={{ isSmallScreen, - isLargeScreen + isTabletScreen, + isNarrowDesktop, + isLargeScreen, + canUseDoublePane }} > {children} diff --git a/src/providers/SettingsSyncProvider.tsx b/src/providers/SettingsSyncProvider.tsx new file mode 100644 index 00000000..acd4a11a --- /dev/null +++ b/src/providers/SettingsSyncProvider.tsx @@ -0,0 +1,252 @@ +import { ApplicationDataKey, BIG_RELAY_URLS } from '@/constants' +import { createSettingsDraftEvent } from '@/lib/draft-event' +import { getReplaceableEventIdentifier } from '@/lib/event' +import client from '@/services/client.service' +import storage, { SETTINGS_CHANGED_EVENT } from '@/services/local-storage.service' +import { TSyncSettings } from '@/types' +import { kinds } from 'nostr-tools' +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { useNostr } from './NostrProvider' + +type TSettingsSyncContext = { + syncSettings: () => Promise<void> + isLoading: boolean +} + +const SettingsSyncContext = createContext<TSettingsSyncContext | undefined>(undefined) + +export const useSettingsSync = () => { + const context = useContext(SettingsSyncContext) + if (!context) { + throw new Error('useSettingsSync must be used within a SettingsSyncProvider') + } + return context +} + +function getCurrentSettings(): TSyncSettings { + return { + themeSetting: storage.getThemeSetting(), + primaryColor: storage.getPrimaryColor(), + defaultZapSats: storage.getDefaultZapSats(), + defaultZapComment: storage.getDefaultZapComment(), + quickZap: storage.getQuickZap(), + autoplay: storage.getAutoplay(), + hideUntrustedInteractions: storage.getHideUntrustedInteractions(), + hideUntrustedNotifications: storage.getHideUntrustedNotifications(), + hideUntrustedNotes: storage.getHideUntrustedNotes(), + nsfwDisplayPolicy: storage.getNsfwDisplayPolicy(), + showKinds: storage.getShowKinds(), + hideContentMentioningMutedUsers: storage.getHideContentMentioningMutedUsers(), + notificationListStyle: storage.getNotificationListStyle(), + mediaAutoLoadPolicy: storage.getMediaAutoLoadPolicy(), + sidebarCollapse: storage.getSidebarCollapse(), + enableSingleColumnLayout: storage.getEnableSingleColumnLayout(), + faviconUrlTemplate: storage.getFaviconUrlTemplate(), + filterOutOnionRelays: storage.getFilterOutOnionRelays(), + quickReaction: storage.getQuickReaction(), + quickReactionEmoji: storage.getQuickReactionEmoji(), + noteListMode: storage.getNoteListMode() + } +} + +function applySettings(settings: TSyncSettings) { + if (settings.themeSetting !== undefined) { + storage.setThemeSetting(settings.themeSetting) + } + if (settings.primaryColor !== undefined) { + storage.setPrimaryColor(settings.primaryColor as any) + } + if (settings.defaultZapSats !== undefined) { + storage.setDefaultZapSats(settings.defaultZapSats) + } + if (settings.defaultZapComment !== undefined) { + storage.setDefaultZapComment(settings.defaultZapComment) + } + if (settings.quickZap !== undefined) { + storage.setQuickZap(settings.quickZap) + } + if (settings.autoplay !== undefined) { + storage.setAutoplay(settings.autoplay) + } + if (settings.hideUntrustedInteractions !== undefined) { + storage.setHideUntrustedInteractions(settings.hideUntrustedInteractions) + } + if (settings.hideUntrustedNotifications !== undefined) { + storage.setHideUntrustedNotifications(settings.hideUntrustedNotifications) + } + if (settings.hideUntrustedNotes !== undefined) { + storage.setHideUntrustedNotes(settings.hideUntrustedNotes) + } + if (settings.nsfwDisplayPolicy !== undefined) { + storage.setNsfwDisplayPolicy(settings.nsfwDisplayPolicy) + } + if (settings.showKinds !== undefined) { + storage.setShowKinds(settings.showKinds) + } + if (settings.hideContentMentioningMutedUsers !== undefined) { + storage.setHideContentMentioningMutedUsers(settings.hideContentMentioningMutedUsers) + } + if (settings.notificationListStyle !== undefined) { + storage.setNotificationListStyle(settings.notificationListStyle) + } + if (settings.mediaAutoLoadPolicy !== undefined) { + storage.setMediaAutoLoadPolicy(settings.mediaAutoLoadPolicy) + } + if (settings.sidebarCollapse !== undefined) { + storage.setSidebarCollapse(settings.sidebarCollapse) + } + if (settings.enableSingleColumnLayout !== undefined) { + storage.setEnableSingleColumnLayout(settings.enableSingleColumnLayout) + } + if (settings.faviconUrlTemplate !== undefined) { + storage.setFaviconUrlTemplate(settings.faviconUrlTemplate) + } + if (settings.filterOutOnionRelays !== undefined) { + storage.setFilterOutOnionRelays(settings.filterOutOnionRelays) + } + if (settings.quickReaction !== undefined) { + storage.setQuickReaction(settings.quickReaction) + } + if (settings.quickReactionEmoji !== undefined) { + storage.setQuickReactionEmoji(settings.quickReactionEmoji) + } + if (settings.noteListMode !== undefined) { + storage.setNoteListMode(settings.noteListMode) + } +} + +export function SettingsSyncProvider({ children }: { children: React.ReactNode }) { + const { pubkey, account, publish } = useNostr() + const [isLoading, setIsLoading] = useState(false) + const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null) + const lastSyncedSettingsRef = useRef<string | null>(null) + + const fetchRemoteSettings = useCallback(async (): Promise<TSyncSettings | null> => { + if (!pubkey) return null + + try { + const relayList = await client.fetchRelayList(pubkey) + const relays = relayList.write.concat(BIG_RELAY_URLS).slice(0, 5) + + const events = await client.fetchEvents(relays, { + kinds: [kinds.Application], + authors: [pubkey], + '#d': [ApplicationDataKey.SETTINGS], + limit: 1 + }) + + const settingsEvent = events + .filter((e) => getReplaceableEventIdentifier(e) === ApplicationDataKey.SETTINGS) + .sort((a, b) => b.created_at - a.created_at)[0] + + if (settingsEvent) { + try { + return JSON.parse(settingsEvent.content) as TSyncSettings + } catch { + return null + } + } + } catch (err) { + console.error('Failed to fetch remote settings:', err) + } + return null + }, [pubkey]) + + const syncSettings = useCallback(async () => { + if (!pubkey || !account) return + + const currentSettings = getCurrentSettings() + const settingsJson = JSON.stringify(currentSettings) + + // Don't sync if settings haven't changed since last sync + if (settingsJson === lastSyncedSettingsRef.current) { + return + } + + setIsLoading(true) + try { + const draftEvent = createSettingsDraftEvent(currentSettings) + await publish(draftEvent) + lastSyncedSettingsRef.current = settingsJson + } catch (err) { + console.error('Failed to sync settings:', err) + } finally { + setIsLoading(false) + } + }, [pubkey, account, publish]) + + // Debounced sync on settings change + const debouncedSync = useCallback(() => { + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current) + } + syncTimeoutRef.current = setTimeout(() => { + syncSettings() + }, 2000) + }, [syncSettings]) + + // Load settings from network on login + useEffect(() => { + if (!pubkey) { + lastSyncedSettingsRef.current = null + return + } + + const loadRemoteSettings = async () => { + setIsLoading(true) + try { + const currentSettings = getCurrentSettings() + const currentSettingsJson = JSON.stringify(currentSettings) + + const remoteSettings = await fetchRemoteSettings() + if (remoteSettings) { + const remoteSettingsJson = JSON.stringify(remoteSettings) + + // Only apply and reload if settings are different + if (currentSettingsJson !== remoteSettingsJson) { + applySettings(remoteSettings) + lastSyncedSettingsRef.current = JSON.stringify(getCurrentSettings()) + // Trigger a page reload to apply the settings + window.location.reload() + } else { + lastSyncedSettingsRef.current = currentSettingsJson + } + } else { + // No remote settings, use current as baseline + lastSyncedSettingsRef.current = currentSettingsJson + } + } catch (err) { + console.error('Failed to load remote settings:', err) + } finally { + setIsLoading(false) + } + } + + loadRemoteSettings() + }, [pubkey, fetchRemoteSettings]) + + // Listen for settings changes and sync + useEffect(() => { + if (!pubkey || !account) return + + const handleSettingsChange = () => { + debouncedSync() + } + + // Listen for settings change events + window.addEventListener(SETTINGS_CHANGED_EVENT, handleSettingsChange) + + return () => { + window.removeEventListener(SETTINGS_CHANGED_EVENT, handleSettingsChange) + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current) + } + } + }, [pubkey, account, debouncedSync]) + + return ( + <SettingsSyncContext.Provider value={{ syncSettings, isLoading }}> + {children} + </SettingsSyncContext.Provider> + ) +} diff --git a/src/providers/ThemeProvider.tsx b/src/providers/ThemeProvider.tsx index a74b8799..2fec873c 100644 --- a/src/providers/ThemeProvider.tsx +++ b/src/providers/ThemeProvider.tsx @@ -1,5 +1,5 @@ import { PRIMARY_COLORS, StorageKey, TPrimaryColor } from '@/constants' -import storage from '@/services/local-storage.service' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { TTheme, TThemeSetting } from '@/types' import { createContext, useContext, useEffect, useState } from 'react' @@ -74,11 +74,13 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { const updateThemeSetting = (themeSetting: TThemeSetting) => { storage.setThemeSetting(themeSetting) setThemeSetting(themeSetting) + dispatchSettingsChanged() } const updatePrimaryColor = (color: TPrimaryColor) => { storage.setPrimaryColor(color) setPrimaryColor(color) + dispatchSettingsChanged() } return ( diff --git a/src/providers/UserPreferencesProvider.tsx b/src/providers/UserPreferencesProvider.tsx index 9302aefe..b82be8a2 100644 --- a/src/providers/UserPreferencesProvider.tsx +++ b/src/providers/UserPreferencesProvider.tsx @@ -1,4 +1,4 @@ -import storage from '@/services/local-storage.service' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { TEmoji, TNotificationStyle } from '@/types' import { createContext, useContext, useEffect, useState } from 'react' import { useScreenSize } from './ScreenSizeProvider' @@ -34,7 +34,7 @@ export const useUserPreferences = () => { } export function UserPreferencesProvider({ children }: { children: React.ReactNode }) { - const { isSmallScreen } = useScreenSize() + const { canUseDoublePane } = useScreenSize() const [notificationListStyle, setNotificationListStyle] = useState( storage.getNotificationListStyle() ) @@ -47,36 +47,41 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod const [quickReactionEmoji, setQuickReactionEmoji] = useState(storage.getQuickReactionEmoji()) useEffect(() => { - if (!isSmallScreen && enableSingleColumnLayout) { + if (canUseDoublePane && enableSingleColumnLayout) { document.documentElement.style.setProperty('overflow-y', 'scroll') } else { document.documentElement.style.removeProperty('overflow-y') } - }, [enableSingleColumnLayout, isSmallScreen]) + }, [enableSingleColumnLayout, canUseDoublePane]) const updateNotificationListStyle = (style: TNotificationStyle) => { setNotificationListStyle(style) storage.setNotificationListStyle(style) + dispatchSettingsChanged() } const updateSidebarCollapse = (collapse: boolean) => { setSidebarCollapse(collapse) storage.setSidebarCollapse(collapse) + dispatchSettingsChanged() } const updateEnableSingleColumnLayout = (enable: boolean) => { setEnableSingleColumnLayout(enable) storage.setEnableSingleColumnLayout(enable) + dispatchSettingsChanged() } const updateQuickReaction = (enable: boolean) => { setQuickReaction(enable) storage.setQuickReaction(enable) + dispatchSettingsChanged() } const updateQuickReactionEmoji = (emoji: string | TEmoji) => { setQuickReactionEmoji(emoji) storage.setQuickReactionEmoji(emoji) + dispatchSettingsChanged() } return ( @@ -88,7 +93,7 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod updateMuteMedia: setMuteMedia, sidebarCollapse, updateSidebarCollapse, - enableSingleColumnLayout: isSmallScreen ? true : enableSingleColumnLayout, + enableSingleColumnLayout: !canUseDoublePane ? true : enableSingleColumnLayout, updateEnableSingleColumnLayout, quickReaction, updateQuickReaction, diff --git a/src/providers/UserTrustProvider.tsx b/src/providers/UserTrustProvider.tsx index e904e2d1..26f790c5 100644 --- a/src/providers/UserTrustProvider.tsx +++ b/src/providers/UserTrustProvider.tsx @@ -1,6 +1,6 @@ import client from '@/services/client.service' import fayan from '@/services/fayan.service' -import storage from '@/services/local-storage.service' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { useNostr } from './NostrProvider' @@ -84,16 +84,19 @@ export function UserTrustProvider({ children }: { children: React.ReactNode }) { const updateHideUntrustedInteractions = (hide: boolean) => { setHideUntrustedInteractions(hide) storage.setHideUntrustedInteractions(hide) + dispatchSettingsChanged() } const updateHideUntrustedNotifications = (hide: boolean) => { setHideUntrustedNotifications(hide) storage.setHideUntrustedNotifications(hide) + dispatchSettingsChanged() } const updateHideUntrustedNotes = (hide: boolean) => { setHideUntrustedNotes(hide) storage.setHideUntrustedNotes(hide) + dispatchSettingsChanged() } return ( diff --git a/src/providers/ZapProvider.tsx b/src/providers/ZapProvider.tsx index 51fa5195..c496311e 100644 --- a/src/providers/ZapProvider.tsx +++ b/src/providers/ZapProvider.tsx @@ -1,5 +1,5 @@ import lightningService from '@/services/lightning.service' -import storage from '@/services/local-storage.service' +import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { onConnected, onDisconnected } from '@getalby/bitcoin-connect-react' import { GetInfoResponse, WebLNProvider } from '@webbtc/webln-types' import { createContext, useContext, useEffect, useState } from 'react' @@ -57,16 +57,19 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { const updateDefaultSats = (sats: number) => { storage.setDefaultZapSats(sats) setDefaultZapSats(sats) + dispatchSettingsChanged() } const updateDefaultComment = (comment: string) => { storage.setDefaultZapComment(comment) setDefaultZapComment(comment) + dispatchSettingsChanged() } const updateQuickZap = (quickZap: boolean) => { storage.setQuickZap(quickZap) setQuickZap(quickZap) + dispatchSettingsChanged() } return ( diff --git a/src/routes/primary.tsx b/src/routes/primary.tsx index c6def55e..5f69f429 100644 --- a/src/routes/primary.tsx +++ b/src/routes/primary.tsx @@ -1,5 +1,4 @@ import BookmarkPage from '@/pages/primary/BookmarkPage' -import ExplorePage from '@/pages/primary/ExplorePage' import MePage from '@/pages/primary/MePage' import NoteListPage from '@/pages/primary/NoteListPage' import NotificationListPage from '@/pages/primary/NotificationListPage' @@ -17,7 +16,6 @@ type RouteConfig = { const PRIMARY_ROUTE_CONFIGS: RouteConfig[] = [ { key: 'home', component: NoteListPage }, - { key: 'explore', component: ExplorePage }, { key: 'notifications', component: NotificationListPage }, { key: 'me', component: MePage }, { key: 'profile', component: ProfilePage }, diff --git a/src/routes/secondary.tsx b/src/routes/secondary.tsx index 00e91108..f54c815e 100644 --- a/src/routes/secondary.tsx +++ b/src/routes/secondary.tsx @@ -5,6 +5,8 @@ import ExternalContentPage from '@/pages/secondary/ExternalContentPage' import FollowingListPage from '@/pages/secondary/FollowingListPage' import FollowPackPage from '@/pages/secondary/FollowPackPage' import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage' +import LoginPage from '@/pages/secondary/LoginPage' +import LogoutPage from '@/pages/secondary/LogoutPage' import MuteListPage from '@/pages/secondary/MuteListPage' import NoteListPage from '@/pages/secondary/NoteListPage' import NotePage from '@/pages/secondary/NotePage' @@ -27,6 +29,8 @@ import { isValidElement } from 'react' // Right column routes const SECONDARY_ROUTE_CONFIGS = [ + { path: '/login', element: <LoginPage /> }, + { path: '/logout', element: <LogoutPage /> }, { path: '/notes', element: <NoteListPage /> }, { path: '/notes/:id', element: <NotePage /> }, { path: '/users', element: <ProfileListPage /> }, diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 17f931c5..34eefc3b 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -590,3 +590,9 @@ class LocalStorageService { const instance = new LocalStorageService() export default instance + +// Custom event for settings sync +export const SETTINGS_CHANGED_EVENT = 'smesh-settings-changed' +export function dispatchSettingsChanged() { + window.dispatchEvent(new CustomEvent(SETTINGS_CHANGED_EVENT)) +} diff --git a/src/services/media-upload.service.ts b/src/services/media-upload.service.ts index e39442ac..0066a1e4 100644 --- a/src/services/media-upload.service.ts +++ b/src/services/media-upload.service.ts @@ -1,3 +1,4 @@ +import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants' import { simplifyUrl } from '@/lib/url' import { TDraftEvent, TMediaUploadServiceConfig } from '@/types' import { BlossomClient } from 'blossom-client-sdk' @@ -84,7 +85,12 @@ class MediaUploadService { } startPseudoProgress() - const servers = await client.fetchBlossomServerList(pubkey) + let servers = await client.fetchBlossomServerList(pubkey) + // Add recommended servers as fallback + const uniqueServers = new Set(servers) + RECOMMENDED_BLOSSOM_SERVERS.forEach((s) => uniqueServers.add(s)) + servers = Array.from(uniqueServers) + if (servers.length === 0) { throw new Error('No Blossom services available') } @@ -94,16 +100,109 @@ class MediaUploadService { message: 'Uploading media file' }) - // first upload blob to main server - const blob = await BlossomClient.uploadBlob(mainServer, file, { auth }) + // Try each server until one succeeds + let blob: { url: string; sha256?: string; size?: number; type?: string; nip94?: string[][] } | undefined + let lastError: Error | undefined + const allServers = [mainServer, ...mirrorServers] + + // Upload with timeout using XMLHttpRequest (works better on mobile) + const uploadWithXHR = (server: string): Promise<typeof blob> => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const uploadUrl = server.replace(/\/$/, '') + '/upload' + + xhr.open('PUT', uploadUrl) + xhr.setRequestHeader('Authorization', 'Nostr ' + btoa(JSON.stringify(auth))) + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream') + xhr.responseType = 'json' + xhr.timeout = 15000 // 15 second timeout per server + + const handleAbort = () => { + xhr.abort() + reject(new Error(UPLOAD_ABORTED_ERROR_MSG)) + } + if (options?.signal) { + if (options.signal.aborted) return handleAbort() + options.signal.addEventListener('abort', handleAbort, { once: true }) + } + + xhr.ontimeout = () => reject(new Error('Upload timed out')) + xhr.onerror = () => reject(new Error('Network error')) + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + const data = xhr.response + if (data?.url) { + resolve({ + url: data.url, + sha256: data.sha256, + size: data.size, + type: data.type, + nip94: data.nip94 + }) + } else { + reject(new Error('No URL in response')) + } + } else { + reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)) + } + } + xhr.send(file) + }) + } + + for (const server of allServers) { + try { + // Try XHR first (better mobile support) + blob = await uploadWithXHR(server) + break + } catch (xhrErr) { + console.error(`Blossom XHR upload failed for ${server}:`, xhrErr) + // Fallback to SDK with timeout + try { + const sdkPromise = BlossomClient.uploadBlob(server, file, { auth }) + const timeoutPromise = new Promise<never>((_, reject) => + setTimeout(() => reject(new Error('SDK upload timed out')), 15000) + ) + const sdkBlob = await Promise.race([sdkPromise, timeoutPromise]) + blob = { + url: sdkBlob.url, + sha256: sdkBlob.sha256, + size: sdkBlob.size, + type: sdkBlob.type, + nip94: (sdkBlob as any).nip94 + } + break + } catch (err) { + console.error(`Blossom SDK upload failed for ${server}:`, err) + lastError = err instanceof Error ? err : new Error(String(err)) + } + } + } + + if (!blob) { + throw lastError ?? new Error('All Blossom servers failed') + } + // Main upload finished stopPseudoProgress() options?.onProgress?.(80) - if (mirrorServers.length > 0) { - await Promise.allSettled( - mirrorServers.map((server) => BlossomClient.mirrorBlob(server, blob, { auth })) - ) + // Mirror to other servers (best effort) - only if we have sha256 + if (blob.sha256) { + const successServer = blob.url ? new URL(blob.url).origin : mainServer + const otherServers = allServers.filter((s) => s !== successServer) + if (otherServers.length > 0) { + const blobDescriptor = { + url: blob.url, + sha256: blob.sha256, + size: blob.size ?? 0, + type: blob.type ?? file.type, + uploaded: Date.now() + } + await Promise.allSettled( + otherServers.map((server) => BlossomClient.mirrorBlob(server, blobDescriptor, { auth })) + ) + } } let tags: string[][] = [] diff --git a/src/types/index.d.ts b/src/types/index.d.ts index a0a5beed..88379520 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -98,15 +98,13 @@ export interface ISigner { nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string> } -export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' | 'npub' +export type TSignerType = 'nsec' | 'nip-07' | 'browser-nsec' | 'ncryptsec' | 'npub' export type TAccount = { pubkey: string signerType: TSignerType ncryptsec?: string nsec?: string - bunker?: string - bunkerClientSecretKey?: string npub?: string } @@ -196,3 +194,27 @@ export type TMediaAutoLoadPolicy = (typeof MEDIA_AUTO_LOAD_POLICY)[keyof typeof MEDIA_AUTO_LOAD_POLICY] export type TNsfwDisplayPolicy = (typeof NSFW_DISPLAY_POLICY)[keyof typeof NSFW_DISPLAY_POLICY] + +export type TSyncSettings = { + themeSetting?: TThemeSetting + primaryColor?: string + defaultZapSats?: number + defaultZapComment?: string + quickZap?: boolean + autoplay?: boolean + hideUntrustedInteractions?: boolean + hideUntrustedNotifications?: boolean + hideUntrustedNotes?: boolean + nsfwDisplayPolicy?: TNsfwDisplayPolicy + showKinds?: number[] + hideContentMentioningMutedUsers?: boolean + notificationListStyle?: TNotificationStyle + mediaAutoLoadPolicy?: TMediaAutoLoadPolicy + sidebarCollapse?: boolean + enableSingleColumnLayout?: boolean + faviconUrlTemplate?: string + filterOutOnionRelays?: boolean + quickReaction?: boolean + quickReactionEmoji?: string | TEmoji + noteListMode?: TNoteListMode +} diff --git a/tailwind.config.js b/tailwind.config.js index a906f78f..449069b1 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,6 +3,9 @@ export default { darkMode: ['class'], content: ['./index.html', './src/**/*.{ts,tsx}'], theme: { + fontFamily: { + sans: ['"Noto Sans"', '"Noto Color Emoji"', 'ui-sans-serif', 'system-ui', 'sans-serif'] + }, extend: { borderRadius: { lg: 'var(--radius)', @@ -12,6 +15,9 @@ export default { '2xl': 'calc(var(--radius) + 8px)' }, colors: { + chrome: { + background: 'hsl(var(--chrome-background))' + }, surface: { background: 'hsl(var(--surface-background))' },